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.
inset-block-end
Block-end edge offset. Maps to bottom in horizontal-tb, left in vertical-rl, right in vertical-lr.
inset-inline-start
Inline-start edge offset. Maps to left in LTR horizontal-tb, right in RTL, top in vertical-rl.
inset-inline-end
Inline-end edge offset. Maps to right in LTR horizontal-tb, left in RTL, bottom in vertical-rl.
inset-block
Shorthand for inset-block-start and inset-block-end. One value sets both; two values set start then end.
inset
Shorthand for all four: block-start, inline-end, block-end, inline-start (same TRBL order as margin).
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
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.
z-index ≥ 1 overlap the consent dialog BCR from outside the dialog subtree — overlay attack covering consent text.
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
- MCP server CSS inset-block-start security — below-fold push, above-top pull, z-index overlay, mousedown injection
- MCP server CSS inset-block security — block-axis shorthand bilateral collapse, off-top and off-bottom attacks
- MCP server CSS inset-inline-start security — off-right-edge push, LTR/RTL axis swap, mousedown injection