MCP server CSS shape-margin security: CSS Shapes float collapse, consent text narrow-column displacement, shape-outside exclusion zone, and runtime shape injection attacks
Published 2026-08-20 — SkillAudit Research
CSS Shapes Level 1 introduces the shape-outside property, which defines a geometric shape that inline content must wrap around when flowing past a float. The companion property shape-margin adds an exclusion margin around that shape — extending the zone that inline text must avoid. The security issue emerges from a fundamental property of how shape-margin interacts with container width: when a floated element's shape exclusion zone (shape boundary plus shape-margin) exceeds the container width, the remaining text column width collapses to zero. Consent text in that container wraps into an infinitely tall zero-width column — rendered as a single-character-wide stack of letters with overflow: hidden making the overflow invisible. The text is present in the DOM and passes textContent checks, but is not readable by any human user.
The attack is notable because shape-outside and shape-margin are applied to the float element, not to the consent text element itself. A scanner that audits the consent element's own CSS will see nothing suspicious — the shape properties are on a sibling float. The consent text element's own display, visibility, opacity, and color all appear normal.
Detection gap: Standard consent visibility checks inspect the consent element's own computed styles: display, visibility, opacity, color, font-size, overflow. A shape-margin attack places the suspicious property on a floated sibling. The consent element itself has no anomalous styles — only its effective rendered width, which collapses to zero due to the sibling's exclusion zone, is abnormal. Detecting this requires computing the effective available inline width after float exclusions, which no standard CSS scanner does.
Attack 1 (SA-CSS-SHPMA-001): shape-outside circle(0px) + extreme shape-margin collapses consent column to zero width
A zero-radius circle shape (shape-outside: circle(0px)) contributes no geometric area, but shape-margin adds margin around even a zero-radius shape. Setting shape-margin to a value equal to or greater than the container width causes the exclusion zone to fill the entire container, leaving zero width for the adjacent consent text to flow into:
/* MCP attack: zero-radius shape + maximum shape-margin collapses text column */
.consent-container {
width: 400px;
overflow: hidden;
/* container appears normal — all consent text IS in the DOM */
}
.shape-attack-float {
float: left;
width: 1px;
height: 100%; /* full container height */
shape-outside: circle(0px);
shape-margin: 400px; /* margin equals container width */
/* exclusion zone: circle(0px) + 400px margin = 400px radius from float edge */
/* entire 400px container width consumed by exclusion zone */
/* remaining column for text: 400px - 400px = 0px */
/* text wraps into 0-width column → each character on its own "line" → clips */
}
.consent-text {
/* no suspicious properties here — this is what the scanner sees */
color: #222;
font-size: 14px;
/* but effective rendered width = 0px due to sibling float's shape-margin */
/* overflow: hidden on container clips the 0-width text stack */
}
/* Detection: must inspect float siblings of consent elements */
function detectShapeMarginCollapse(consentEl) {
const container = consentEl.closest('[style*="overflow"]') || consentEl.parentElement;
const containerWidth = container.getBoundingClientRect().width;
// Find all floated siblings
const floats = [...container.querySelectorAll('*')].filter(el => {
const cs = getComputedStyle(el);
return cs.float !== 'none' && el !== consentEl;
});
for (const floatEl of floats) {
const cs = getComputedStyle(floatEl);
const shapeOutside = cs.shapeOutside;
const shapeMarginStr = cs.shapeMargin;
if (shapeOutside && shapeOutside !== 'none') {
const shapeMarginPx = parseFloat(shapeMarginStr) || 0;
const floatRect = floatEl.getBoundingClientRect();
const exclusionZone = shapeMarginPx + floatRect.width;
if (exclusionZone >= containerWidth * 0.8) {
return {
severity: 'Critical',
finding: 'SA-CSS-SHPMA-001',
floatElement: floatEl.tagName,
shapeOutside,
shapeMargin: shapeMarginStr,
containerWidth,
exclusionZone,
reason: `Float sibling has shape-outside: "${shapeOutside}" + shape-margin: ${shapeMarginStr}. Exclusion zone (${exclusionZone}px) is ≥80% of container width (${containerWidth}px). Consent text column width may be collapsed to near-zero — text is DOM-present but not rendered readably.`,
};
}
}
}
return null;
}
Attack 2 (SA-CSS-SHPMA-002): shape-outside polygon + shape-margin pushes consent text into off-screen strip
Rather than collapsing the column entirely, a polygon shape combined with shape-margin can push the consent text to the right of the polygon's convex hull. If the polygon's rightmost point plus shape-margin exceeds the container width, the text overflows to the right — beyond a overflow: hidden clip boundary — while appearing to fill the container from the developer tools perspective:
/* MCP attack: polygon shape-outside + shape-margin pushes text off right edge */
.consent-container {
width: 360px;
overflow: hidden;
position: relative;
}
.shape-deflect-float {
float: left;
width: 280px;
height: 200px;
shape-outside: polygon(0 0, 100% 0, 100% 100%, 0 100%);
/* polygon covers full float width — forces text to start at 280px */
shape-margin: 100px;
/* shape exclusion: 280px polygon + 100px margin = 380px from left */
/* container width: 360px — text column starts at 380px, beyond container */
/* all text overflows to right, clipped by overflow: hidden */
background: transparent; /* float is invisible — appears to be whitespace */
}
/* Variant: triangle polygon deflects text below the visible viewport */
.shape-funnel-float {
float: left;
width: 0;
height: 500px;
shape-outside: polygon(0 0, 100% 0, 50% 100%);
/* triangle: text must flow to the right of the widening triangle base */
shape-margin: 20px;
/* at the top: text starts at 20px (narrow) */
/* at the bottom: triangle is 0+20px wide — text column exists but at bottom */
/* by scrolling the dialog to show only the top: bottom consent text is hidden */
}
/* Detection: check if consent element's rendered bounding rect is within viewport */
function detectShapeDisplacement(consentEl) {
const rect = consentEl.getBoundingClientRect();
const containerRect = consentEl.parentElement.getBoundingClientRect();
// Check if consent text is actually rendered within its container
const textRange = document.createRange();
textRange.selectNodeContents(consentEl);
const textRects = [...textRange.getClientRects()];
const outOfContainer = textRects.filter(r =>
r.right > containerRect.right + 2 ||
r.left < containerRect.left - 2 ||
r.bottom > containerRect.bottom + 2
);
if (outOfContainer.length > 0 && textRects.length > 0) {
const ratio = outOfContainer.length / textRects.length;
return {
severity: ratio > 0.5 ? 'Critical' : 'High',
finding: 'SA-CSS-SHPMA-002',
outOfContainerRatio: ratio,
outOfContainerRects: outOfContainer.length,
totalRects: textRects.length,
reason: `${Math.round(ratio * 100)}% of consent text line boxes are outside the container's clipping boundary. Shape-outside float exclusion zones may be displacing consent text to an overflow-hidden region.`,
};
}
return null;
}
Attack 3 (SA-CSS-SHPMA-003): shape-margin on stacked floats creates bilateral squeeze leaving no text column
Two floats — one left, one right — each with shape-outside and shape-margin — can squeeze the consent text column from both sides simultaneously. When the combined exclusion zones from both floats exceed the container width, the text column width goes negative (clamped to zero). This bilateral squeeze is harder to detect because neither float's exclusion zone alone fills the container:
/* MCP attack: bilateral float squeeze — left + right shape-margin exclusions overlap */
.consent-container {
width: 400px;
overflow: hidden;
}
/* Left float: shape-outside + shape-margin excludes from left */
.shape-left {
float: left;
width: 50px;
height: 100%;
shape-outside: inset(0px); /* rectangle = full float area */
shape-margin: 160px; /* exclusion from left edge: 50px + 160px = 210px */
background: transparent;
}
/* Right float: shape-outside + shape-margin excludes from right */
.shape-right {
float: right;
width: 50px;
height: 100%;
shape-outside: inset(0px);
shape-margin: 160px; /* exclusion from right edge: 50px + 160px = 210px */
background: transparent;
}
/* Combined: 210px from left + 210px from right = 420px consumed in 400px container */
/* Available text width: 400px - 210px - 210px = -20px → clamped to 0 */
/* Consent text collapses to zero-width column — invisible via overflow: hidden */
/* Detection: measure both floats' combined exclusion zones */
function detectBilateralSqueezeAttack(consentEl) {
const container = consentEl.parentElement;
const containerWidth = container.getBoundingClientRect().width;
const leftFloats = [...container.querySelectorAll('*')].filter(el => {
const cs = getComputedStyle(el);
return cs.float === 'left' && cs.shapeOutside && cs.shapeOutside !== 'none';
});
const rightFloats = [...container.querySelectorAll('*')].filter(el => {
const cs = getComputedStyle(el);
return cs.float === 'right' && cs.shapeOutside && cs.shapeOutside !== 'none';
});
let leftExclusion = 0;
for (const f of leftFloats) {
const cs = getComputedStyle(f);
leftExclusion += f.getBoundingClientRect().width + (parseFloat(cs.shapeMargin) || 0);
}
let rightExclusion = 0;
for (const f of rightFloats) {
const cs = getComputedStyle(f);
rightExclusion += f.getBoundingClientRect().width + (parseFloat(cs.shapeMargin) || 0);
}
const remainingWidth = containerWidth - leftExclusion - rightExclusion;
if (remainingWidth < containerWidth * 0.2 && (leftFloats.length + rightFloats.length) >= 2) {
return {
severity: 'Critical',
finding: 'SA-CSS-SHPMA-003',
containerWidth,
leftExclusion,
rightExclusion,
remainingWidth,
reason: `Bilateral shape-outside float squeeze: left exclusion (${leftExclusion}px) + right exclusion (${rightExclusion}px) = ${leftExclusion + rightExclusion}px in ${containerWidth}px container. Remaining text column width: ${remainingWidth}px — consent text rendered in a zero-width strip.`,
};
}
return null;
}
Attack 4 (SA-CSS-SHPMA-004): JS mousedown injects shape-margin float to collapse consent at install time
The shape-outside + shape-margin float can be injected at mousedown — the moment the user commits to clicking the install button. At audit time the consent text is fully visible; at install commit time the injected float collapses the text column. The injected float is a zero-size transparent div — it leaves no visual trace:
/* MCP JS: inject shape-margin float at mousedown to collapse consent */
document.querySelector('.install-btn').addEventListener('mousedown', () => {
const consentContainer = document.querySelector('.consent-text-container');
// Inject zero-size float with maximum shape-margin
const attackFloat = document.createElement('div');
attackFloat.style.cssText = `
float: left;
width: 1px;
height: 100%;
shape-outside: circle(0px);
shape-margin: ${consentContainer.offsetWidth}px;
position: relative;
background: transparent;
pointer-events: none;
`;
// Insert as first child — float must precede the text in DOM order to affect it
consentContainer.insertBefore(attackFloat, consentContainer.firstChild);
// At mousedown: consent text collapses to 0-width
// At click: user confirms install with invisible consent
// At mouseup: optionally remove float to restore appearance post-install
}, { capture: true });
document.querySelector('.install-btn').addEventListener('mouseup', () => {
// Remove float after click — consent reappears — no visual anomaly persists
const attackFloat = document.querySelector('[style*="shape-outside"]');
if (attackFloat) attackFloat.remove();
}, { capture: true });
/* Detection: MutationObserver watching consent container for float injection */
function detectRuntimeShapeMarginInjection(consentContainer) {
const injections = [];
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
const cs = getComputedStyle(node);
if (cs.float !== 'none' && cs.shapeOutside && cs.shapeOutside !== 'none') {
injections.push({
severity: 'Critical',
finding: 'SA-CSS-SHPMA-004',
shapeOutside: cs.shapeOutside,
shapeMargin: cs.shapeMargin,
float: cs.float,
reason: `Float with shape-outside: "${cs.shapeOutside}" + shape-margin: ${cs.shapeMargin} injected into consent container at runtime. Dynamic float injection may collapse the consent text column at install time.`,
});
}
}
}
});
observer.observe(consentContainer, { childList: true, subtree: true });
return { observer, injections };
}
Safe baseline: Legitimate consent dialogs have no reason to use shape-outside or shape-margin on any element inside or adjacent to a consent container. Any shape-outside value other than none on a float sibling of consent text is a High finding. Any such float whose exclusion zone (float width + shape-margin) exceeds 50% of the container width is Critical. Runtime float injection detected by MutationObserver is Critical regardless of shape-margin value.
Attack summary
| ID | Attack | Mechanism | Detection point | Severity |
|---|---|---|---|---|
| SA-CSS-SHPMA-001 | Zero-radius circle + max shape-margin collapses column | shape-outside: circle(0px) + shape-margin ≥ container width → zero remaining text column → overflow-hidden clips text → consent present in DOM but not rendered |
Inspect float siblings of consent element; compute float width + shape-margin vs container width | Critical |
| SA-CSS-SHPMA-002 | Polygon + shape-margin deflects text off right edge | Wide polygon float + shape-margin displaces text column start beyond container right edge; all text overflows into hidden region; float is transparent | Compare consent text getClientRects() against container bounding rect; flag rects outside container |
Critical |
| SA-CSS-SHPMA-003 | Bilateral left+right float squeeze | Left float and right float each contribute partial exclusion zones; combined exclusion > container width; neither float alone triggers single-float checks | Sum left and right float exclusion zones; remaining width <20% of container = Critical | Critical |
| SA-CSS-SHPMA-004 | Runtime float injection at mousedown | JS inserts float with shape-outside + shape-margin at mousedown; consent collapses at install commit; float removed at mouseup restoring appearance | MutationObserver on consent container watching for float element insertion | Critical |
Finding blocks
shape-outside + shape-margin whose combined exclusion zone ≥ container width. Consent text is compressed into a zero-width column — DOM-present but not user-readable. Check all float siblings, not just the consent element itself.
getClientRects() line boxes fall outside the container's bounding rect. A float's shape exclusion zone is deflecting the text column start beyond the container's right or bottom edge. Overflow-hidden clips displaced text — invisible to user, present in DOM.
shape-outside exclusion zones whose sum exceeds the container width. Neither float triggers a single-float check; the combined exclusion collapses the text column. Sum all float exclusion zones on both sides.
shape-outside injected into the consent container at mousedown. Dynamic float injection collapses consent text at install commit time while it was visible at audit time. Flag all runtime float insertions inside or adjacent to consent containers.
← Blog | column-rule attacks | clip-path attacks | Security Checklist