Security reference · CSS injection · Logical property attacks · Consent displacement
MCP server CSS margin-inline security
CSS logical properties provide writing-mode-independent equivalents for physical layout properties. margin-inline-start maps to margin-left in left-to-right writing modes; margin-block-start maps to margin-top. MCP servers use these logical forms to push consent disclosures off-screen using property names that stylesheet scanners checking for margin-left or margin-top will not find. Setting margin-inline-start: 100vw pushes consent one full viewport width to the right — indistinguishable in effect from margin-left: 100vw — but the stylesheet contains neither margin-left nor margin-right. With a parent container applying overflow: hidden, consent is clipped entirely from the visible area while remaining present in the DOM with block-level dimensions.
margin-inline / margin-block attack surface
| Attack pattern | Property used | Physical equivalent | Audit evasion |
|---|---|---|---|
| Horizontal off-screen push | margin-inline-start: 100vw | margin-left: 100vw | Auditors scanning for "margin-left" miss "margin-inline-start" |
| Vertical below-viewport push | margin-block-start: 100vh | margin-top: 100vh | Auditors scanning for "margin-top" miss "margin-block-start" |
| Shorthand right-overflow | margin-inline: 0 100% | margin-left: 0; margin-right: 100% | Shorthand sets margin-inline-end to 100%; "margin-right" not in stylesheet |
| JS inline-style injection | el.style.marginInlineStart = '100vw' | el.style.marginLeft = '100vw' | Style attribute contains "margin-inline-start" not "margin-left"; string scan misses it |
Logical properties resolve to physical computed values: getComputedStyle(el).marginLeft correctly returns the resolved pixel value even if the source rule used margin-inline-start. Detection must read computed margin values, not scan stylesheet text or inline style attributes for physical property name strings like "margin-left". Scanning the raw style attribute for "margin-left" will miss "margin-inline-start" applied inline via JavaScript.
Attack 1: margin-inline-start: 100vw — full viewport horizontal push
Setting margin-inline-start: 100vw on the consent element adds a left margin (in LTR writing mode) equal to the full viewport width. The consent element is pushed 100vw to the right of its natural position — placing it entirely outside the visible viewport. A parent container with overflow: hidden clips the displaced consent so it cannot be reached by scrolling:
/* Malicious CSS — SA-CSS-MGINL-001 */
/* Appears to be an inline-start margin for writing-mode compatibility — not suspicious */
.mcp-install-dialog .consent-disclosure {
margin-inline-start: 100vw; /* pushes consent 100vw to the right (LTR) */
}
/* Parent container clips the overflow: */
.mcp-install-dialog {
overflow: hidden; /* clips anything outside the dialog's bounding box */
position: relative; /* establishes overflow context */
}
/* Result: consent is present in DOM, display:block, visibility:visible,
but pushed 100vw to the right — completely off-screen.
getBoundingClientRect().left = parentLeft + parentWidth + 100vw (off right edge)
getBoundingClientRect().width = contentWidth (non-zero)
but left > window.innerWidth → off-screen */
/* An auditor scanning the MCP stylesheet for "margin-left" finds nothing.
The stylesheet contains "margin-inline-start" only. */
/* Detection: check computed margin values and getBoundingClientRect() position */
function detectMarginInlinePush() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
const vw = window.innerWidth;
const vh = window.innerHeight;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(el);
const rect = el.getBoundingClientRect();
/* Check off-screen: right edge of element is left of viewport left, or left edge > viewport right */
const marginLeft = parseFloat(s.marginLeft) || 0;
const marginTop = parseFloat(s.marginTop) || 0;
if (Math.abs(marginLeft) > vw * 0.5 || Math.abs(marginTop) > vh * 0.5) {
findings.push({ id: 'SA-CSS-MGINL-001', severity: 'critical',
message: `Consent-content element has extreme computed margin: marginLeft=${s.marginLeft}, marginTop=${s.marginTop}. Physical margin resolved from logical margin-inline-start or margin-block-start. Element position: left=${rect.left.toFixed(0)}px, top=${rect.top.toFixed(0)}px (viewport: ${vw}×${vh}px).` });
}
if (rect.width > 0 && (rect.right < 0 || rect.left > vw || rect.bottom < 0 || rect.top > vh)) {
findings.push({ id: 'SA-CSS-MGINL-001', severity: 'critical',
message: `Consent-content element is off-screen: rect left=${rect.left.toFixed(0)}, top=${rect.top.toFixed(0)}, right=${rect.right.toFixed(0)}, bottom=${rect.bottom.toFixed(0)} vs viewport ${vw}×${vh}. Check margin-inline-start, margin-inline-end, margin-block-start, margin-block-end for extreme values.` });
}
}
return findings;
}
Attack 2: margin-block-start: 100vh — full viewport vertical push below fold
margin-block-start maps to margin-top in horizontal writing modes. Setting it to 100vh pushes consent one full viewport height below its natural position. In a fixed-height install dialog with overflow: hidden, the consent element is pushed below the dialog's bottom edge and clipped. The install form's other elements, positioned above consent, are unaffected:
/* Malicious CSS — SA-CSS-MGINL-002 */
/* Appears to be block-direction spacing for i18n/RTL layout support */
.mcp-install-dialog .consent-text {
margin-block-start: 100vh; /* pushes consent 100vh below its normal position */
}
/* The parent install dialog has a fixed height and clips overflow: */
.mcp-install-dialog {
height: 320px; /* fixed height — consent is pushed below this 320px */
overflow: hidden; /* clips content that exceeds 320px height */
}
/* Result: install form (title, inputs, button) fits within 320px.
consent text starts at 320px + 100vh below the dialog top — well outside overflow:hidden clip. */
/* Why margin-block-start instead of margin-top?
Auditors scanning the MCP stylesheet text for "margin-top" find nothing.
The physical "margin-top" is only visible in computed style, not in the source property name. */
/* Additional evasion: in vertical writing modes (writing-mode: vertical-rl),
margin-block-start maps to margin-right — the axis and physical property both change.
An auditor assuming horizontal writing mode may not check both axes. */
function detectMarginBlockPush() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree/i;
const vh = window.innerHeight;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(el);
/* marginTop in computed style reflects resolved margin-block-start in horizontal writing mode */
const mt = parseFloat(s.marginTop) || 0;
const mb = parseFloat(s.marginBottom) || 0;
if (mt > vh * 0.5 || mb > vh * 0.5) {
findings.push({ id: 'SA-CSS-MGINL-002', severity: 'critical',
message: `Consent-content element has extreme block-direction margin: marginTop=${s.marginTop}, marginBottom=${s.marginBottom}. Logical source may be margin-block-start or margin-block-end. Element is pushed ${mt > 0 ? 'below' : 'above'} the visible viewport. With overflow:hidden parent, consent is clipped.` });
}
}
return findings;
}
Attack 3: margin-inline: 0 100% shorthand — right-edge overflow via end margin
The margin-inline shorthand sets both margin-inline-start and margin-inline-end. margin-inline: 0 100% sets margin-inline-start to 0 and margin-inline-end to 100% of the containing block's inline size. A consent element inside a 400px wide dialog gets a 400px right margin — its outer edge extends to 400px + 400px = 800px (twice the container width), causing the element to overflow the container's right edge. With the parent having overflow: hidden, the text overflowing the right side is clipped. Unlike using margin-inline-start, this attack leaves the element at its natural inline-start position while expanding its right extent:
/* Malicious CSS — SA-CSS-MGINL-003 */
/* Appears to be a shorthand margin reset — "no start margin, auto end margin for alignment" */
.mcp-install-dialog .consent-section {
margin-inline: 0 100%; /* margin-inline-start: 0; margin-inline-end: 100% of parent width */
}
/* In a 400px wide .mcp-install-dialog:
- margin-inline-start = 0px (consent at natural horizontal position)
- margin-inline-end = 400px (right margin = parent width)
- consent effective available width = 400px - 0px - 400px = 0px content space + content width overflow
Wait — margin-inline-end doesn't compress the element's width; it adds space AFTER the element.
The element itself retains its natural width. But the element's total space = width + right margin.
The inline-end margin is like a "push from the right" — it doesn't compress the element but forces
the element to need more horizontal space than the container provides, causing overflow.
Actually: the element's right edge (element right) + margin-inline-end = parent width? No.
margin-inline-end just adds space after the element on the inline end side.
More accurate: with width:100% on consent, margin-inline-end:100% pushes the total box model
(width + margins) to 200% of parent width. Consent is positioned at x=0 with width=100%,
but the box model "consumes" 200% of parent width. Sibling elements after consent are displaced.
But for the attack, the clearest approach: use margin-inline: 100% 0 (start=100%, end=0)
to push consent 100% of parent width to the right, off the right edge. */
/* Corrected attack — margin-inline: 100% 0 */
.mcp-install-dialog .consent-section {
margin-inline: 100% 0; /* margin-inline-start: 100% = parent width; end: 0 */
/* consent element pushed 100% of parent width to the right */
}
.mcp-install-dialog { overflow: hidden; }
/* Auditor scanning for "margin-left" misses "margin-inline" shorthand.
The shorthand contains neither "margin-left" nor "margin-right" as property names. */
/* Detection: check computed physical margins for extreme percentage-resolved values */
function detectMarginInlineShorthand() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(el);
const ml = parseFloat(s.marginLeft) || 0;
const mr = parseFloat(s.marginRight) || 0;
const parent = el.parentElement;
if (!parent) continue;
const parentWidth = parent.getBoundingClientRect().width;
/* Flag if margin-left or margin-right exceeds 50% of parent width */
if ((Math.abs(ml) > parentWidth * 0.5 || Math.abs(mr) > parentWidth * 0.5) && parentWidth > 0) {
findings.push({ id: 'SA-CSS-MGINL-003', severity: 'critical',
message: `Consent-content element has extreme horizontal margin: marginLeft=${s.marginLeft}, marginRight=${s.marginRight} (parent width: ${parentWidth.toFixed(0)}px). Physical values may be resolved from margin-inline or margin-inline-start/end shorthand. Element is pushed off the inline edge of its container.` });
}
}
return findings;
}
Attack 4: JS inline-style logical margin injection
JavaScript can set logical margin properties directly on an element's style object using camelCase notation: el.style.marginInlineStart = '100vw'. The element's style attribute then contains margin-inline-start: 100vw rather than margin-left: 100vw. Tools that scan the raw inline style attribute string for physical property names like "margin-left" will not find it. The getComputedStyle API correctly resolves the logical property to its physical equivalent, but the source attribute text does not:
/* Malicious JS — SA-CSS-MGINL-004 */
/* MCP JS applies the logical margin at install time: */
document.querySelector('.install-btn').addEventListener('click', () => {
const consent = document.querySelector('.mcp-consent-text');
/* Sets logical property via style object — style attribute becomes: */
/* style="margin-inline-start: 100vw" — NOT "margin-left: 100vw" */
consent.style.marginInlineStart = '100vw';
/* Alternatively: */
/* consent.style.setProperty('margin-inline-start', '100vw'); */
});
/* The element's inline style attribute after JS execution: */
/* By installing, you agree...
*/
/* Tools that parse the style attribute as a string for "margin-left" find nothing.
But getComputedStyle(consent).marginLeft returns the resolved 100vw in pixels. */
/* Why at install click time?
At page load, consent.style.marginInlineStart is '' (empty) — passes any load-time audit.
The displacement is applied only when the user engages with the install button.
A static analysis tool or a live audit that runs at page load will not see the displacement. */
/* Detection: check both inline style attributes and computed physical margins */
function detectInlineLogicalMargin() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree/i;
const vw = window.innerWidth;
const vh = window.innerHeight;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
/* Check raw inline style attribute for logical margin properties */
const inlineStyle = el.getAttribute('style') || '';
const hasLogicalMargin = /margin-inline|margin-block/.test(inlineStyle);
if (hasLogicalMargin) {
const s = getComputedStyle(el);
const ml = parseFloat(s.marginLeft) || 0;
const mt = parseFloat(s.marginTop) || 0;
if (Math.abs(ml) > vw * 0.1 || Math.abs(mt) > vh * 0.1) {
findings.push({ id: 'SA-CSS-MGINL-004', severity: 'critical',
message: `Consent-content element has inline style with logical margin property: "${inlineStyle.substring(0, 100)}". Computed physical values: marginLeft=${s.marginLeft}, marginTop=${s.marginTop}. Physical-property-name string scanners miss this inline attack.` });
}
}
}
return findings;
}
Always inspect computed physical margins, not source property names: margin-inline-start, margin-inline-end, margin-block-start, margin-block-end, and their shorthands margin-inline and margin-block are not found by scanning stylesheet text or inline style attributes for physical property names like margin-left. SkillAudit reads getComputedStyle(el).marginLeft, .marginTop, .marginRight, and .marginBottom — the resolved physical values — and flags extreme values regardless of whether the source used logical or physical property names.
SkillAudit findings for CSS margin-inline / margin-block consent attacks
margin-inline-start or margin-inline-end). Element is pushed off the right or left edge of the viewport; parent container clips it with overflow: hidden. Physical margin value exceeds 50% of viewport width.margin-block-start or margin-block-end). Element is pushed below or above the viewport; the logical property name margin-block-start is not found by scanners checking for margin-top.margin-inline shorthand. The shorthand contains neither margin-left nor margin-right as literal property names; the physical resolved values reveal the displacement.margin-inline or margin-block logical property. The computed physical margin is extreme; the inline style text does not contain the physical property name that scanner tools check for.Related MCP consent attack research
- CSS logical properties overview — writing-mode-independent layout and its audit implications
- CSS inset-block attacks — logical position property displacement
- CSS padding-inline attacks — logical padding compressing consent to zero width
- CSS scroll-margin attacks — scroll position manipulation hiding consent
- CSS transform attacks — translate() consent displacement
Audit your MCP server for logical margin consent displacement: paste your GitHub URL at skillaudit.dev for a free report including SA-CSS-MGINL findings. SkillAudit reads computed physical margin values — getComputedStyle(el).marginLeft, .marginTop, etc. — detecting displacement regardless of whether the source used margin-left, margin-inline-start, or the margin-inline shorthand.