Security Guide

MCP server CSS padding-inline-start security — border-box inline-start content crush, em-relative font-size coupling, writing-mode axis remap, JS mousedown injection

CSS padding-inline-start is the individual logical property for the start-side inline padding of an element. In horizontal-tb with direction: ltr, it maps to physical padding-left. A large padding-inline-start on a consent dialog with a fixed inline-size and box-sizing: border-box crushes the content area from the left, shifting all content rightward until form controls and the approve button overflow or clip off the end edge. Because the dialog's computed width remains unchanged, audits checking offsetWidth alone will miss the attack — only reading the logical property value directly or checking the approve button's own BCR reveals it.

CSS padding-inline-start — property overview

The padding-inline-start property sets padding at the inline-start edge of an element's content box. In horizontal-tb LTR it maps to physical padding-left; in horizontal-tb RTL, to padding-right; in vertical-rl, to padding-top; in vertical-lr, to padding-bottom. It is a sub-property of padding-inline (shorthand) and padding (shorthand). Related: padding-block-start, padding-inline-end, padding-block shorthand.

Attack 1: large padding-inline-start + border-box — crushing content from the inline start

When a consent dialog has a fixed inline-size (or width) and box-sizing: border-box, the content area width equals the inline size minus all horizontal padding and borders. Setting a large padding-inline-start consumes that space from the left, pushing all content toward the inline-end edge. Form controls, consent text, and the approve button are all shifted right. At sufficient values the approve button overflows the content area and is clipped by overflow: hidden, or is pushed beyond the dialog's right edge and remains partially or fully invisible. The dialog's reported offsetWidth is unchanged; only the content area width calculation and the approve button's BCR expose the attack.

/* Attack: border-box consent dialog — padding-inline-start crushes content from the left */
.consent-dialog {
  inline-size: 400px !important;
  box-sizing: border-box !important;
  overflow: hidden !important;
  padding-inline-start: 370px !important; /* 370px left padding → 30px content area */
}

/* Effect:
   Content area width = 400 - 370 = 30px
   All consent text and the approve button are crammed into 30px
   dialog.offsetWidth = 400px → width audit passes
   getComputedStyle(dialog).paddingLeft = "370px" (in LTR) → padding audit catches it
   getPropertyValue('padding-inline-start') = "370px" → always catches it regardless of dir */

function checkPaddingInlineStartCrush(consentEl) {
  const cs  = getComputedStyle(consentEl);
  const pis = parseFloat(cs.getPropertyValue('padding-inline-start')) || 0;
  const pie = parseFloat(cs.getPropertyValue('padding-inline-end'))   || 0;
  const w   = consentEl.offsetWidth || 0;
  const bw  = (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
  const contentArea = w - bw - pis - pie;
  const approveBtn = consentEl.querySelector('[data-action="allow"], .approve-btn, button[type="submit"]');
  const btnBCR = approveBtn ? approveBtn.getBoundingClientRect() : null;
  return {
    paddingInlineStart: pis,
    contentAreaWidth:   contentArea,
    contentCrushed:     contentArea < 40,
    buttonVisible: btnBCR
      ? (btnBCR.left < window.innerWidth && btnBCR.right > 0)
      : null,
  };
}

Inline-start padding attacks are direction-dependent: in LTR layouts the content shifts right; in RTL layouts padding-inline-start maps to physical padding-right and content shifts left. An auditor who checks paddingLeft in an RTL dialog reads zero and misses the attack. Always read the logical property name directly.

Attack 2: em-relative padding-inline-start + injected font-size — two-phase threshold evasion

A padding-inline-start expressed in em units multiplies by the element's computed font-size. An attacker first injects a large font-size on the consent dialog (or an ancestor), then sets padding-inline-start to a modest number of ems that individually appears below any per-pixel threshold. The two-phase injection keeps each injection step small; only after both values are applied does the resolved padding reach an attack magnitude. A single-property audit that checks padding-inline-start before font-size is changed reads the pre-injection em value and finds nothing suspicious.

/* Phase 1: inject large font-size on the consent container */
consentEl.style.setProperty('font-size', '80px', 'important');

/* Phase 2: set padding-inline-start in ems — appears modest per em */
consentEl.style.setProperty('padding-inline-start', '4.5em', 'important');

/* Resolved: 4.5 × 80px = 360px left padding on a 400px-wide dialog
   Content area = 400 - 360 = 40px → approve button is essentially invisible

   A per-pixel threshold check sees "4.5em" and may compute 4.5 × 16 = 72px (baseline)
   — below any typical 200px warning threshold — and reports clean.
   The resolved value (360px) is the number that matters. */

function checkEmRelativePaddingInlineStart(consentEl) {
  const cs = getComputedStyle(consentEl);
  const resolvedPx = parseFloat(cs.getPropertyValue('padding-inline-start')) || 0;
  const fontSize   = parseFloat(cs.fontSize) || 16;
  const w          = consentEl.offsetWidth || 0;
  return {
    paddingInlineStartPx: resolvedPx,
    fontSize:             fontSize,
    contentAreaWidth:     w - resolvedPx,
    suspicious: resolvedPx > fontSize * 4,
  };
}

Attack 3: writing-mode: vertical-rlpadding-inline-start maps to physical padding-top

In writing-mode: vertical-rl, the inline axis runs vertically (top to bottom by default in vertical-rl). padding-inline-start maps to physical padding-top. An audit reading getComputedStyle(el).paddingLeft finds zero and reports no attack. The attack instead crushes the content area from the top — in vertical-rl flow, content flows downward and the approve button (typically at the inline-end = bottom) is pushed out of the content area's vertical extent. Only reading getPropertyValue('padding-inline-start') (the logical property) or checking the approve button's BCR catches the attack.

/* Attack: vertical-rl + padding-inline-start → physical padding-top */
.consent-wrapper {
  writing-mode: vertical-rl;
}

.consent-dialog {
  block-size: 400px !important;          /* height in vertical-rl = block-size */
  box-sizing: border-box !important;
  overflow: hidden !important;
  padding-inline-start: 360px !important; /* maps to padding-top → crushes from top */
}

/* getComputedStyle(dialog).paddingLeft  = "0px"   → left audit misses it
   getComputedStyle(dialog).paddingTop   = "360px" → top audit catches it
   getPropertyValue('padding-inline-start') = "360px" → logical read always catches it

   In vertical-rl the approve button is at the bottom of the text flow.
   With 360px top padding in a 400px container, only 40px of content area remains.
   The button is off the visible bottom of the element (content overflows or clips). */

function checkPaddingInlineStartWritingMode(consentEl) {
  const cs = getComputedStyle(consentEl);
  return {
    paddingInlineStart: parseFloat(cs.getPropertyValue('padding-inline-start')) || 0,
    paddingLeft:        parseFloat(cs.paddingLeft) || 0,
    writingMode:        cs.writingMode,
    axisMismatch:       cs.writingMode !== 'horizontal-tb',
  };
}

Attack 4: JS mousedown injection — large padding-inline-start at click time

At page load, the consent dialog is correctly laid out and the approve button is visible. A mousedown listener on the approve button injects a large padding-inline-start on the dialog. With box-sizing: border-box, this shifts all dialog content rightward during the press — the approve button is no longer under the pointer and the click fires on whatever element occupies that position in the layout. At mouseup, the padding is removed and the dialog restores. A static audit of the page's loaded state detects no anomaly; only event-listener inspection or a MutationObserver on style changes during mousedown reveals the injection.

/* Mousedown: inject padding-inline-start to shift content during press */
(function () {
  document.querySelectorAll('.approve-btn, [data-action="allow"]').forEach(btn => {
    const dialog = btn.closest('.consent-dialog');
    if (!dialog) return;

    btn.addEventListener('mousedown', () => {
      dialog.style.setProperty('padding-inline-start', '500px', 'important');
    }, { passive: true });

    btn.addEventListener('mouseup',    () => dialog.style.removeProperty('padding-inline-start'), { passive: true });
    btn.addEventListener('mouseleave', () => dialog.style.removeProperty('padding-inline-start'), { passive: true });
  });
})();

Use a MutationObserver on the consent dialog's style attribute to catch mousedown padding injection. When padding-inline-start or padding-left changes while a pointer event is active, immediately re-check both the content area width and the approve button's BCR. A transient inline crush during mousedown is HIGH severity even if the padding is removed at mouseup.

Detection summary

HIGH Content area width ≤ 0 — padding-inline-start alone or combined with padding-inline-end exceeds the dialog's fixed inline size; entire content area is collapsed and form controls are clipped.
HIGH Approve button BCR not in viewport, or BCR width < 4px — button has been shifted off the content area by padding-inline-start combined with border-box and a fixed inline size.
MEDIUM Content area width < 40px — approve button is partially crushed from the start; on narrower viewports or with RTL content reflowing differently, the button may exit the visible area entirely.
MEDIUM Resolved padding-inline-start is > 4× the element's font-size while set in em units — indicates a two-phase em-amplification attack that evades per-pixel threshold checks.
MEDIUM Mousedown listener on approve button injects padding-inline-start on the consent dialog — transient inline crush undetectable at page-load audit time.
/* Complete padding-inline-start consent audit */
function auditPaddingInlineStart(consentEl) {
  const cs  = getComputedStyle(consentEl);
  const pis = parseFloat(cs.getPropertyValue('padding-inline-start')) || 0;
  const pie = parseFloat(cs.getPropertyValue('padding-inline-end'))   || 0;
  const w   = consentEl.offsetWidth || 0;
  const bw  = (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
  const contentArea = w - bw - pis - pie;
  const approveBtn = consentEl.querySelector('[data-action="allow"], .approve-btn, button[type="submit"]');
  const btnBCR = approveBtn ? approveBtn.getBoundingClientRect() : null;
  return {
    paddingInlineStart: pis,
    paddingInlineEnd:   pie,
    inlineSize:         w,
    contentAreaWidth:   Math.max(0, contentArea),
    collapsed:          contentArea <= 0,
    contentCrushed:     contentArea < 40,
    buttonInViewport:   btnBCR
      ? (btnBCR.left < window.innerWidth && btnBCR.right > 0 &&
         btnBCR.top < window.innerHeight && btnBCR.bottom > 0)
      : null,
    writingMode:        cs.writingMode,
    direction:          cs.direction,
  };
}

SkillAudit checks padding-inline-start by reading the logical property directly (catching writing-mode and direction remaps), computing the available content area width against the dialog's fixed inline size and border widths, and verifying the approve button's own BCR independently. Em-relative padding is resolved to pixels before comparison, preventing two-phase font-size amplification evasion. Run a free audit →