Security Guide

MCP server CSS margin-inline-start security — positive inline-width collapse, negative overlap over sibling content, dir=rtl axis swap, JS mousedown injection

CSS margin-inline-start is the logical left margin in ltr and the logical right margin in rtl. On a fixed-width consent container, a large positive margin-inline-start compresses the available text column from the start edge — the dialog narrows or collapses entirely while its outer box remains at full declared width. The attack is invisible to property checks on the dialog's own width and border properties; only the content column is compressed, and only inspecting clientWidth minus computed inline margins reveals it. Negative values create a different class of attack: the container overlaps elements rendered to its left in the flow, potentially covering a security disclosure in an adjacent column.

CSS margin-inline-start — property overview

The margin-inline-start property sets the margin at the inline-start edge of an element — the left side in left-to-right (ltr) writing and the right side in right-to-left (rtl) writing. Margin is external to the box model: it does not change clientWidth, scrollWidth, or getBoundingClientRect().width for the element itself, but it does reduce the content area available between the element's start edge and its containing block. On a width: 400px container with margin-inline-start: 300px, the element is still 400px wide but its left edge is 300px inset from the container start — the leftmost 300px of the viewport-allocated horizontal space is consumed by margin, and the element content may be pushed off-screen to the right or the readable area dramatically reduced. Related properties: margin-inline shorthand, margin-block-start.

Attack 1: large positive margin-inline-start — inline-width collapse pushing consent off the start edge

A consent dialog in a flex or grid layout that occupies a fraction of the available horizontal space can be pushed rightward by a large positive margin-inline-start, effectively moving the dialog off the visible viewport or compressing its text column to near-zero. The dialog element retains its declared width, but its position shifts right. The user sees only a sliver of the dialog or nothing at all. Importantly, the element's getBoundingClientRect().width remains unchanged — only its left coordinate shifts beyond the viewport edge.

/* Consent dialog in a 480px container */
.consent-wrapper {
  width: 480px;
  overflow: hidden; /* clips the shifted dialog */
}

/* Attack: push the dialog 460px right — nearly entirely off-screen */
.consent-dialog {
  margin-inline-start: 460px !important;
}

/* Effect:
   consent-dialog BCR: { left: 460, width: 480, right: 940 }
   Visible portion within 480px wrapper: 480 - 460 = 20px of the dialog is visible
   The approve button (near the right edge of the dialog) may remain clickable
   but the consent text is fully clipped.

   Audit checking consent-dialog width: 480 — PASS.
   Audit checking consent-dialog visibility: visible — PASS.
   Only a BCR viewport clip check (BCR.left vs container width) reveals the collapse. */

/* With overflow:visible on the wrapper, the dialog extends rightward into adjacent content */
.consent-wrapper {
  width: 480px;
  overflow: visible;
}
/* Now the dialog overlaps the next column to the right — covering other UI elements. */

Margin-inline-start shift moves the dialog's left edge, not its width. An audit checking getBoundingClientRect().width or clientWidth finds the expected value. Only comparing BCR.left against the containing block width, or checking that BCR.right <= viewportWidth, catches the off-screen shift.

Attack 2: negative margin-inline-start — consent dialog overlaps content to its left

A negative margin-inline-start on the consent container pulls it leftward, potentially covering security disclosures or warnings rendered in a left-side panel or column. In a two-column layout where the left column contains risk information and the right column contains the consent dialog, a sufficiently large negative margin-inline-start on the consent column shifts it leftward into the disclosure column. The disclosure element's own properties are unchanged — it is the consent container's margin that creates the overlap.

/* Two-column layout: left=disclosure, right=consent */
.layout {
  display: grid;
  grid-template-columns: 1fr 1fr;
  width: 800px;
}

/* Attack: negative margin shifts consent leftward into disclosure column */
.consent-dialog {
  margin-inline-start: -360px !important; /* shifts 360px left */
  position: relative !important;
  z-index: 10 !important;
}

/* Effect:
   .security-disclosure BCR: { left: 0, right: 400 }  — unchanged
   .consent-dialog BCR:       { left: 400 } normally → { left: 40 } after attack
   The consent container covers the right ~360px of the disclosure column.

   .security-disclosure own properties: clean — PASS.
   Only a cross-element BCR overlap check detects the coverage. */

function detectNegativeInlineStartOverlap(consentEl) {
  const cs  = getComputedStyle(consentEl);
  const mis = parseFloat(cs.getPropertyValue('margin-inline-start')) || 0;
  if (mis >= 0) return { attack: false };

  const cBCR = consentEl.getBoundingClientRect();
  const overlapTargets = [];

  // Walk previous siblings and elements in adjacent grid/flex columns
  const parent = consentEl.parentElement;
  for (const child of (parent?.children ?? [])) {
    if (child === consentEl) continue;
    const r = child.getBoundingClientRect();
    if (Math.min(cBCR.right, r.right) > Math.max(cBCR.left, r.left) &&
        Math.min(cBCR.bottom, r.bottom) > Math.max(cBCR.top, r.top)) {
      overlapTargets.push(child);
    }
  }

  return { attack: mis < -20, overlapTargets };
}

Attack 3: dir="rtl" axis swap — inline-start maps to the physical right

In a right-to-left (rtl) context, margin-inline-start controls the physical right margin, not the left. An attacker who can inject dir="rtl" on the consent container or a parent element changes the physical meaning of every logical margin property. A security scanner checking marginLeft (physical) on the element finds zero — the attack is on marginRight (physical), which is what margin-inline-start resolves to in RTL. The content within the dialog also re-flows right-to-left, which may itself impair readability of consent text.

/* RTL injection: margin-inline-start now controls physical right margin */
.consent-dialog {
  direction: rtl !important;           /* or dir="rtl" in HTML */
  margin-inline-start: 300px !important; /* → physical margin-right: 300px */
}

/* In a 480px container, this pushes the dialog 300px from its right edge:
   In RTL, content is positioned from the right. A 300px margin-inline-start
   (= physical right) moves the dialog 300px inward from the right edge of its
   container, potentially pushing it past the left boundary.

   Scanner checking: getComputedStyle(el).marginLeft → "0px" — no alert.
   Must use: getComputedStyle(el).getPropertyValue('margin-inline-start') to read
   the logical property regardless of direction. */

// Detection: always read logical property names, not physical aliases
function checkInlineStartMargin(el) {
  const cs  = getComputedStyle(el);
  const dir = cs.direction || el.closest('[dir]')?.getAttribute('dir') || 'ltr';
  const mis = parseFloat(cs.getPropertyValue('margin-inline-start')) || 0;
  const mie = parseFloat(cs.getPropertyValue('margin-inline-end'))   || 0;

  // Physical mapping depends on dir
  const physicalLeft  = dir === 'rtl' ? mie : mis;
  const physicalRight = dir === 'rtl' ? mis : mie;

  return { marginInlineStart: mis, marginInlineEnd: mie, physicalLeft, physicalRight, dir };
}

dir="rtl" changes logical-to-physical mapping for all margin, padding, and border logical properties. Never audit consent layout using physical property names (marginLeft, paddingRight). Always read logical names via getPropertyValue('margin-inline-start') and map to physical sides yourself using the element's resolved direction.

Attack 4: JS mousedown injection of margin-inline-start — at click time

At page load the consent dialog is correctly positioned. A mousedown listener on the approve button injects a large margin-inline-start, immediately shifting the dialog at the moment the user clicks. The consent text slides off the start edge for the duration of the press. The user's click registers on the approve button — which has not moved — while the consent content is hidden. At mouseup, the margin is removed and the dialog restores. Static analysis and page-load snapshots find no issue.

/* Mousedown injection: shift consent dialog at click time */
(function () {
  const DIALOG  = '.consent-dialog';
  const APPROVE = '.approve-btn, [data-action="allow"]';

  function shiftStart() {
    document.querySelectorAll(DIALOG).forEach(el => {
      const w = el.clientWidth;
      el.style.setProperty('margin-inline-start', `${w - 20}px`, 'important');
    });
  }

  function restore() {
    document.querySelectorAll(DIALOG).forEach(el => {
      el.style.removeProperty('margin-inline-start');
    });
  }

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

Mousedown-injected inline-start shifts require runtime monitoring. Use a MutationObserver watching style mutations on the consent container. When a margin-inline-start change is detected, immediately check whether BCR.left >= 0 and BCR.right <= viewportWidth — a shift off the inline-start edge will be caught at the moment of injection.

Detection summary

HIGH Computed margin-inline-start on consent container is positive and causes BCR.left to exceed the containing block width — the dialog is shifted off the visible inline-start edge, potentially hiding consent text while leaving the approve button reachable.
HIGH Computed margin-inline-start is negative — the consent container is shifted toward the inline-start edge, potentially overlapping adjacent content or a security disclosure in a left-side column (LTR) or right-side column (RTL).
MEDIUM dir="rtl" or direction: rtl on the consent container or a parent — logical-to-physical mapping is swapped; physical-side margin checks (marginLeft) read the wrong axis.
MEDIUM Mousedown listener on approve button injects margin-inline-start on the consent container at click time — dialog shifts for the duration of the press, hiding consent text transiently.
/* Full inline-start margin audit */
function auditMarginInlineStart(consentEl) {
  const cs  = getComputedStyle(consentEl);
  const mis = parseFloat(cs.getPropertyValue('margin-inline-start')) || 0;
  const dir = cs.direction;
  const bcr = consentEl.getBoundingClientRect();
  const vw  = window.innerWidth;

  const shiftedOffStart = bcr.left < 0;
  const shiftedOffEnd   = bcr.right > vw;
  const negativeOverlap = mis < -20;

  // Check for mousedown-injecting event listeners
  const btns = consentEl.querySelectorAll('button, [role="button"]');
  let mousedownRisk = false;
  // (In a full audit, clone the node and compare listener presence via getEventListeners
  //  in DevTools protocol — not available in userland JS without instrumentation.)

  return {
    marginInlineStart: mis,
    direction: dir,
    shiftedOffStart,
    shiftedOffEnd,
    negativeOverlap,
    bcrLeft: bcr.left,
    bcrRight: bcr.right,
  };
}

SkillAudit checks margin-inline-start in both LTR and RTL contexts — reading logical property names directly via getPropertyValue(), checking BCR off-screen shifts, and monitoring for mousedown-injected style mutations on the consent container. Run a free audit →