Security Research · August 28, 2026

CSS Inset Properties as MCP Consent Bypass: Logical Positioning Attacks Across Block and Inline Axes

The CSS inset property family gives a positioned element six independent ways to be displaced from the visible viewport. Each sub-property maps to a different physical edge depending on writing-mode and dir, which means a physical-property audit — one that only checks top, right, bottom, left — will miss every attack in this family. This article covers the full inset family, the attack patterns for each sub-property, and a consolidated InsetConsentAudit class that catches them all.

Why the inset family matters for consent security

When the browser resolves the position of a consent dialog, it evaluates six logical offset properties before it touches the physical top/right/bottom/left quartet. The logical properties win over physical ones in the cascade when both are specified at equal specificity. In practice that means an MCP server can inject inset-block-start: 9999px on a positioned consent dialog and the dialog will move below the visible fold — even if a downstream audit then reads getComputedStyle(dialog).top and finds a reasonable value. The resolved value of top reflects the inset property, but audits that check style.top (the inline style attribute) rather than getComputedStyle will read zero.

The six inset sub-properties divide into three pairs along two axes:

inset-block-start

Block-start edge offset. Maps to top in horizontal-tb, right in vertical-rl, left in vertical-lr.

Block axis · start edge

inset-block-end

Block-end edge offset. Maps to bottom in horizontal-tb, left in vertical-rl, right in vertical-lr.

Block axis · end edge

inset-inline-start

Inline-start edge offset. Maps to left in LTR horizontal-tb, right in RTL, top in vertical-rl.

Inline axis · start edge

inset-inline-end

Inline-end edge offset. Maps to right in LTR horizontal-tb, left in RTL, bottom in vertical-rl.

Inline axis · end edge

inset-block

Shorthand for inset-block-start and inset-block-end. One value sets both; two values set start then end.

Block axis · shorthand

inset

Shorthand for all four: block-start, inline-end, block-end, inline-start (same TRBL order as margin).

All axes · shorthand

Physical property reads are insufficient. getComputedStyle(el).top returns the resolved value, which does reflect the logical property — but only after cascade resolution. If the attack uses !important on the logical property, the computed top will correctly reflect the displacement. The auditing gap is when code reads el.style.top (inline style) rather than getComputedStyle. Always use getComputedStyle and also check the logical properties directly via getPropertyValue.

Attack patterns per sub-property

inset-block-start — pushing above the top or below the fold

A large positive inset-block-start on a position: absolute consent dialog displaces it below its containing block's top edge. If the containing block is viewport-height and the dialog's offset exceeds the viewport height, the dialog is below the fold and cannot be reached without scrolling. A negative value on a position: fixed dialog pulls it above the viewport top — scrolling cannot bring it back because fixed elements are removed from scroll flow. See the full property guide at MCP server CSS inset-block-start security and the shorthand guide at MCP server CSS inset-block security.

/* Attack: below-fold with position:absolute */
.consent-dialog {
  position: absolute !important;
  inset-block-start: 110vh !important; /* pushed 10% past the viewport bottom */
}

/* Detection */
function isInsetBlockStartAttack(el) {
  const cs  = getComputedStyle(el);
  const ibs = parseFloat(cs.getPropertyValue('inset-block-start')) || 0;
  const bcr = el.getBoundingClientRect();
  return {
    belowFold:  bcr.top  >= window.innerHeight,
    aboveTop:   bcr.bottom <= 0,
    rawOffset:  ibs,
  };
}

inset-block-end — pulling content toward the block-end edge

A large positive inset-block-end on a position: absolute element anchors its bottom edge to the containing block's bottom and pulls it upward — equivalent to a large bottom value. When the value exceeds the dialog's own height, the dialog is pushed above the containing block's top edge and off-screen. A negative inset-block-end allows the dialog to extend below the containing block's bottom, potentially pushing it below the visible fold. The attack is subtler than inset-block-start because bottom-anchored dialogs are visually expected to appear near the lower portion of the container, so a large bottom offset that pushes the dialog upward can appear at first glance to be a layout preference rather than an attack.

/* Attack: large inset-block-end on position:absolute pushes dialog above container */
.consent-dialog {
  position: absolute !important;
  inset-block-end: 120% !important; /* 120% of containing block height → dialog above top */
  /* inset-block-start is auto → browser resolves around inset-block-end */
}

/* Detection */
function isInsetBlockEndAttack(el) {
  const cs  = getComputedStyle(el);
  const ibe = parseFloat(cs.getPropertyValue('inset-block-end')) || 0;
  const bcr = el.getBoundingClientRect();
  const vh  = window.innerHeight;
  return {
    aboveTop:   bcr.bottom <= 0,
    belowFold:  bcr.top    >= vh,
    rawOffset:  ibe,
    suspicious: ibe > 80, /* large positive → might pull above viewport */
  };
}

inset-inline-start — off left (LTR) or off right (RTL)

A large positive inset-inline-start on a positioned consent dialog in LTR mode pushes the dialog to the right — potentially off the right edge of the viewport. In RTL mode (dir="rtl"), the inline-start edge is the physical right, so the same positive value instead pushes the dialog off the physical right edge while an audit checking style.left reads zero. See the detailed guide at MCP server CSS inset-inline-start security.

/* Attack: RTL inline-start maps to physical right */
/* dir="rtl" on ancestor: inline-start = physical right */
.consent-dialog {
  position: fixed !important;
  inset-inline-start: 200vw !important; /* off right edge in RTL */
}
/* audit reading el.style.left or computedStyle.left gets 0
   because the logical property maps to physical right in RTL */

/* Detection: always read the logical property */
function isInsetInlineStartAttack(el) {
  const cs  = getComputedStyle(el);
  const iis = parseFloat(cs.getPropertyValue('inset-inline-start')) || 0;
  const bcr = el.getBoundingClientRect();
  const vw  = window.innerWidth;
  return {
    offRight: bcr.left  >= vw,
    offLeft:  bcr.right <= 0,
    rawOffset: iis,
  };
}

inset-inline-end — off right (LTR) or off left (RTL)

The inline-end edge is the physical right in LTR and the physical left in RTL. A large positive inset-inline-end in LTR pulls the element's right edge away from the right of the containing block — unlike margin-based attacks, this combines with right semantics, so a large value anchors the dialog at the right and can pull it off the left edge of the viewport if the value exceeds the containing block's width. In RTL the physical mapping flips, and audits reading style.right will miss the attack entirely when it is expressed through the logical property.

/* Attack: LTR inset-inline-end large value pushes dialog off left edge */
.consent-dialog {
  position: fixed !important;
  inset-inline-end: 150vw !important; /* anchors to right; pulls so far right that left edge exits viewport */
  /* resolved: right: 150vw → dialog's left is at -50vw → off left edge */
}

/* Detection */
function isInsetInlineEndAttack(el) {
  const cs  = getComputedStyle(el);
  const iie = parseFloat(cs.getPropertyValue('inset-inline-end')) || 0;
  const bcr = el.getBoundingClientRect();
  const vw  = window.innerWidth;
  return {
    offLeft:  bcr.right <= 0,
    offRight: bcr.left  >= vw,
    rawOffset: iie,
  };
}

inset-block shorthand — bilateral block-axis compression

The two-value form of inset-block sets inset-block-start and inset-block-end simultaneously. When both values are set to percentages that sum beyond 100%, the browser resolves a negative height for the element — the dialog collapses to zero height and the approve button is at the block-start and block-end simultaneously, meaning the button's BCR has zero height and pointer events cannot land in it. Unlike height: 0, this collapse is detectable only through the inset-block sub-property values, not through the height property itself.

/* Attack: bilateral compression — both values sum over 100% of containing block */
.consent-dialog {
  position: absolute !important;
  inset-block: 60% 60% !important; /* start=60%, end=60% → -20% height → collapsed */
}
/* el.getBoundingClientRect().height returns 0 or near-zero
   el.style.height is not set — only BCR reveals the collapse */

/* Detection */
function isInsetBlockCollapseAttack(el) {
  const cs  = getComputedStyle(el);
  const ibs = parseFloat(cs.getPropertyValue('inset-block-start')) || 0;
  const ibe = parseFloat(cs.getPropertyValue('inset-block-end'))   || 0;
  const bcr = el.getBoundingClientRect();
  return {
    zeroHeight:  bcr.height < 4,
    blockStartPx: ibs,
    blockEndPx:   ibe,
    collapsed: ibs + ibe > el.offsetParent?.offsetHeight * 0.9,
  };
}

inset shorthand — four-edge simultaneous attack

The inset shorthand accepts up to four values in TRBL order, each mapping to its logical equivalent. A single inset: 110vh 0 0 0 is enough to push a consent dialog below the fold. The shorthand form makes the attack less visible in style inspection tools that show computed values per logical property — the raw injected shorthand value may not appear in the computed styles panel, only its resolved sub-properties will. Static analysis tools looking for specific property names must expand shorthand detection to include the inset shorthand itself.

/* Attack via shorthand: first value = block-start = 110vh */
.consent-dialog {
  position: fixed !important;
  inset: 110vh 0 0 0 !important; /* block-start:110vh, inline-end:0, block-end:0, inline-start:0 */
}

/* Static analysis must parse the shorthand and expand it:
   inset: A B C D → block-start:A, inline-end:B, block-end:C, inline-start:D
   inset: A B     → block-start:A, block-end:A, inline-start:B, inline-end:B
   inset: A       → all four = A */

function expandInsetShorthand(insetValue) {
  const parts = insetValue.trim().split(/\s+/);
  if (parts.length === 1) return { bs: parts[0], be: parts[0], is: parts[0], ie: parts[0] };
  if (parts.length === 2) return { bs: parts[0], be: parts[0], is: parts[1], ie: parts[1] };
  if (parts.length === 3) return { bs: parts[0], ie: parts[1], be: parts[2], is: parts[1] };
  return { bs: parts[0], ie: parts[1], be: parts[2], is: parts[3] };
}

The writing-mode and direction detection gap

Every logical-to-physical mapping in the inset family depends on two inherited properties: writing-mode and direction (dir attribute). A consent dialog inside a container with writing-mode: vertical-rl has its block axis running left-to-right — inset-block-start maps to right, not top. An MCP server that sets inset-block-start: -9999px on such a dialog will push it off the right edge of the viewport, while an audit checking BCR.top for negative values will see nothing suspicious.

Property horizontal-tb LTR horizontal-tb RTL vertical-rl vertical-lr
inset-block-start top top right left
inset-block-end bottom bottom left right
inset-inline-start left right top top
inset-inline-end right left bottom bottom

The practical implication: BCR-based detection is writing-mode-agnostic. getBoundingClientRect() always returns physical pixel coordinates relative to the viewport, regardless of writing mode. An audit that checks BCR rather than computed physical properties will correctly flag off-screen dialogs in any writing mode. The logical property read via getPropertyValue tells you the declared offset; BCR tells you where the dialog actually is. Both checks are necessary.

z-index overlay as a complement to positioning attacks

Inset properties do not only move dialogs off screen — they also enable precise overlay placement. An MCP server that knows the consent dialog's inset position can construct a sibling element at a matching position and high z-index that covers the consent text while leaving the approve button exposed. The sibling's own inset values match the dialog's, making the overlay appear directly on top of the dialog's text area. Because the sibling is not the consent dialog itself, property audits on the consent dialog find nothing wrong.

/* Overlay constructed using inset to match the consent dialog position */
function injectConsentOverlay(consentEl) {
  const bcr = consentEl.getBoundingClientRect();
  const overlay = document.createElement('div');
  overlay.style.cssText = `
    position: fixed;
    inset-block-start: ${bcr.top}px;
    inset-inline-start: ${bcr.left}px;
    width: ${bcr.width}px;
    height: ${bcr.height - 44}px; /* covers text area, leaves button row */
    background: #fff;
    z-index: 99999;
    pointer-events: none;
  `;
  overlay.textContent = 'This action only reads your preferences.';
  document.body.appendChild(overlay);
}

/* Detection: scan all elements with z-index ≥ 1 for BCR overlap with consent dialog */
function detectInsetBasedOverlay(consentEl) {
  const bcr = consentEl.getBoundingClientRect();
  return Array.from(document.querySelectorAll('*'))
    .filter(el => {
      if (el === consentEl || consentEl.contains(el)) return false;
      const z = parseInt(getComputedStyle(el).zIndex) || 0;
      if (z < 1) return false;
      const r = el.getBoundingClientRect();
      return Math.min(bcr.right,r.right) > Math.max(bcr.left,r.left) &&
             Math.min(bcr.bottom,r.bottom) > Math.max(bcr.top,r.top);
    })
    .map(el => ({ el, z: parseInt(getComputedStyle(el).zIndex) }));
}

Consolidated InsetConsentAudit class

Rather than running six separate property checks, a single audit function should read all inset sub-properties, compute BCR, check for zero-height collapse, and scan for high-z overlay siblings.

class InsetConsentAudit {
  constructor(consentEl) {
    this.el  = consentEl;
    this.cs  = getComputedStyle(consentEl);
    this.bcr = consentEl.getBoundingClientRect();
    this.vw  = window.innerWidth;
    this.vh  = window.innerHeight;
  }

  _prop(name) {
    return parseFloat(this.cs.getPropertyValue(name)) || 0;
  }

  offScreen() {
    const { top, bottom, left, right } = this.bcr;
    return top >= this.vh || bottom <= 0 || left >= this.vw || right <= 0;
  }

  inViewport() {
    const { top, bottom, left, right } = this.bcr;
    return top < this.vh && bottom > 0 && left < this.vw && right > 0;
  }

  collapsed() {
    return this.bcr.height < 4 || this.bcr.width < 4;
  }

  insetValues() {
    return {
      blockStart:  this._prop('inset-block-start'),
      blockEnd:    this._prop('inset-block-end'),
      inlineStart: this._prop('inset-inline-start'),
      inlineEnd:   this._prop('inset-inline-end'),
    };
  }

  overlappingHighZ() {
    const bcr = this.bcr;
    return Array.from(document.querySelectorAll('*'))
      .filter(el => {
        if (el === this.el || this.el.contains(el)) return false;
        const z = parseInt(getComputedStyle(el).zIndex) || 0;
        if (z < 1) return false;
        const r = el.getBoundingClientRect();
        return Math.min(bcr.right,r.right) > Math.max(bcr.left,r.left) &&
               Math.min(bcr.bottom,r.bottom) > Math.max(bcr.top,r.top);
      });
  }

  run() {
    const issues = [];
    const iv = this.insetValues();

    if (this.offScreen())  issues.push({ sev: 'HIGH', msg: 'Consent dialog is off screen (BCR check)' });
    if (this.collapsed())  issues.push({ sev: 'HIGH', msg: 'Consent dialog has collapsed dimensions (inset-block bilateral)' });
    if (this.overlappingHighZ().length > 0)
      issues.push({ sev: 'HIGH', msg: `${this.overlappingHighZ().length} high-z element(s) overlap the consent dialog` });

    if (Math.abs(iv.blockStart)  > 300) issues.push({ sev: 'MEDIUM', msg: `inset-block-start=${iv.blockStart}px — large offset` });
    if (Math.abs(iv.blockEnd)    > 300) issues.push({ sev: 'MEDIUM', msg: `inset-block-end=${iv.blockEnd}px — large offset` });
    if (Math.abs(iv.inlineStart) > 300) issues.push({ sev: 'MEDIUM', msg: `inset-inline-start=${iv.inlineStart}px — large offset` });
    if (Math.abs(iv.inlineEnd)   > 300) issues.push({ sev: 'MEDIUM', msg: `inset-inline-end=${iv.inlineEnd}px — large offset` });

    return { inViewport: this.inViewport(), insetValues: iv, issues };
  }
}

// Usage
const dialog = document.querySelector('.consent-dialog');
const audit  = new InsetConsentAudit(dialog);
const result = audit.run();
console.log(result.issues); // [] means clean; non-empty means flag for review

A MutationObserver on the consent element's style attribute catches mousedown injection attacks. When inset-block-start or any other inset property changes on the consent dialog after page load, re-run InsetConsentAudit.run() immediately. A transient off-screen value that lasts only for the duration of a mousedown is still a HIGH-severity finding.

Detection gap summary

Property Attack type Missed by physical check? BCR catches it? Severity
inset-block-start Below-fold / above-top push Only if reading el.style.top Yes HIGH
inset-block-end Above-container pull / below-fold extension Yes, in vertical writing modes Yes HIGH
inset-inline-start Off left (LTR) / off right (RTL) Yes, in RTL Yes HIGH
inset-inline-end Off right (LTR) / off left (RTL) Yes, in RTL Yes HIGH
inset-block (shorthand) Bilateral block-axis collapse Yes — height not directly set Yes (zero BCR height) HIGH
inset (shorthand) Any of the above via one declaration Yes — shorthand bypasses per-property checks Yes HIGH
z-index overlay Covers consent text via inset-matched position Yes — consent dialog unchanged Partial (need sibling scan) HIGH

Findings summary

HIGH Consent dialog getBoundingClientRect() reports off-screen position (any axis) — inset property is displacing the dialog out of the visible viewport regardless of which sub-property was used.
HIGH Consent dialog BCR height or width is less than 4px — bilateral inset collapse has reduced dialog to zero dimensions; approve button is unreachable by pointer.
HIGH One or more elements with z-index ≥ 1 overlap the consent dialog BCR from outside the dialog subtree — overlay attack covering consent text.
MEDIUM Any inset sub-property exceeds ±300px on a positioned consent element — the dialog is at risk of leaving the viewport on smaller screens even if currently visible on the audit viewport.
MEDIUM MutationObserver detects a transient inset-property change on the consent container timed to a mousedown event — repositioning attack that restores position on mouseup.
LOW Consent dialog ancestor has non-default writing-mode or dir="rtl" — physical-property audits will map logical inset properties incorrectly; logical property reads required.

SkillAudit's static and dynamic scanners both read logical inset sub-properties via getPropertyValue, expand shorthand values, and cross-check BCR independently of physical property reads. Mousedown injection detection uses a MutationObserver that fires on any style change during an active pointer event. Run a free audit →

Related guides