Security Guide
MCP server SVG clipPathUnits consent security — objectBoundingBox fraction coordinates clip consent text to 0.01% of its area, userSpaceOnUse with off-element clip rect, tiny-rect sub-pixel clip, and mousedown injection of zero-size clip path
The SVG <clipPath> element’s clipPathUnits attribute determines the coordinate system in which the clip path geometry is defined. With clipPathUnits="objectBoundingBox", coordinates inside the <clipPath> are expressed as fractions of the clipped element’s bounding box — values between 0 and 1 map to 0% to 100% of the element’s width or height. This allows an attacker to create a <clipPath> with a <rect width="0.001" height="0.001"/> (0.1% × 0.1% of the element’s bounding box = 0.01% of its area) that clips an entire consent text element to a sub-pixel region. The clip-path attribute is present in the DOM and the clipPathUnits attribute is set, but a scanner that checks clip-path coordinates in the wrong coordinate system — treating the 0.001 value as SVG user units — may incorrectly conclude only 1px is clipped, not 99.9% of the text. Four attack patterns: objectBoundingBox tiny clip, userSpaceOnUse off-element rect, empty clipPath (clips everything), and mousedown injection.
Attack 1: clipPathUnits="objectBoundingBox" with rect width="0.001" height="0.001" — 0.01% clip area, consent text invisible (SA-CSS-CPU-001)
When clipPathUnits="objectBoundingBox", the coordinate system for the clip path is a unit square mapped over the clipped element’s bounding box. A value of x=0, y=0, width=1, height=1 would reveal the entire element. Reducing width and height to 0.001 reveals only 0.001 × 0.001 = 0.000001 of the bounding box — effectively a sub-pixel square in the top-left corner of the element. For a consent text element 200px wide and 16px tall, the revealed area is 0.001 × 200 = 0.2px wide and 0.001 × 16 = 0.016px tall — below the pixel threshold for any visible glyph. The rest of the text is clipped.
The key confusion for scanners is the coordinate system. If a scanner reads the clip rect as width="0.001" and interprets it as 0.001 SVG user units (approximately 0.001 pixels at 1:1 scale), it might flag it as suspiciously small. But if it normalizes against some assumed viewport width (e.g., 440px) and concludes 0.001 / 440 = 0.002% of viewport clipped, the diagnosis may still be wrong in the objectBoundingBox system where the clip is relative to the element, not the viewport. The reliable check is: when clipPathUnits="objectBoundingBox", compute the clip area as width_fraction × height_fraction and flag values below a threshold (e.g., 0.01 = 1% of element area).
<!-- SA-CSS-CPU-001: clipPathUnits="objectBoundingBox"; clipRect width=0.001 height=0.001 -->
<!-- clip area = 0.001*BCR.width × 0.001*BCR.height; for text 200×16px: 0.2×0.016 = sub-pixel -->
<!-- 99.99% of consent text clipped; remaining area sub-pixel (invisible) -->
<!-- textContent intact; BCR non-zero; clip-path attribute present -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- clipPathUnits=objectBoundingBox: coordinates are 0-1 fractions of element BCR -->
<!-- width=0.001, height=0.001: clips to 0.1% × 0.1% = 0.01% of element area -->
<clipPath id="clip-cpu-001" clipPathUnits="objectBoundingBox">
<rect x="0" y="0" width="0.001" height="0.001"/>
</clipPath>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- clip-path applied; 99.99% of text clipped; sub-pixel remainder invisible -->
<text x="20" y="50" font-size="13" fill="#111"
clip-path="url(#clip-cpu-001)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: resolve clipPathUnits and compute clip area fraction
function checkClipPathUnits(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const el of svgEl.querySelectorAll('[clip-path]')) {
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const clipPathAttr = el.getAttribute('clip-path') ?? '';
const idMatch = clipPathAttr.match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const clipPathEl = svgEl.getElementById(idMatch[1]) ||
document.getElementById(idMatch[1]);
if (!clipPathEl) continue;
const units = clipPathEl.getAttribute('clipPathUnits') || 'userSpaceOnUse';
const clipRects = Array.from(clipPathEl.querySelectorAll('rect, circle, ellipse, path'));
for (const shape of clipRects) {
if (units === 'objectBoundingBox') {
const w = parseFloat(shape.getAttribute('width') ?? '1') || 0;
const h = parseFloat(shape.getAttribute('height') ?? '1') || 0;
const areaFraction = w * h; // fraction of element bounding box
if (areaFraction < 0.01) { // less than 1% of element area visible
const bcr = el.getBoundingClientRect();
const visibleW = w * bcr.width;
const visibleH = h * bcr.height;
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-CPU-001',
note: `clipPathUnits="${units}"; clipRect w=${w} h=${h} (objectBoundingBox fractions); ` +
`clip area fraction=${(areaFraction*100).toFixed(4)}%; ` +
`element BCR w=${bcr.width.toFixed(0)}px h=${bcr.height.toFixed(0)}px; ` +
`visible area=${visibleW.toFixed(2)}×${visibleH.toFixed(2)}px (sub-pixel → invisible); ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero; ` +
`scanner misinterpreting w=0.001 as user-unit px would miss this; ` +
`detection: when clipPathUnits=objectBoundingBox, clip area = w*h fraction; flag <0.01`
});
}
}
}
}
return findings;
}
CRITICAL — SA-CSS-CPU-001: clipPathUnits="objectBoundingBox" with rect width="0.001" height="0.001" clips the consent text element to 0.01% of its bounding box area — a sub-pixel region. The clip-path attribute is present in the DOM, the textContent is intact, and the BCR is non-zero. A scanner that reads the clip rect width as 0.001 and interprets it in SVG user-unit space (0.001px) may flag it as small but cannot correctly assess severity without resolving the clipPathUnits coordinate system. Detection: when clipPathUnits="objectBoundingBox", compute the visible area fraction as clip_width × clip_height and flag fractions below 1%.
Attack 2: clipPathUnits="userSpaceOnUse" with clip rect positioned entirely off the text element (SA-CSS-CPU-002)
The default clipPathUnits="userSpaceOnUse" uses the SVG viewport coordinate system (same as text element coordinates). An attacker creates a clip rect positioned completely outside the text element’s bounding box — for example, rect x="-5000" y="-5000" width="10" height="10" in SVG user units, which is a 10×10 region centered at −5000,−5000. Since the text element’s bounding box is at approximately (20, 37)–(220, 53) in SVG user units, the clip rect does not intersect with the text at all. The SVG renderer clips the element to the intersection of its geometry with the clip region — which is empty. The text is completely invisible.
The attack is distinct from the objectBoundingBox variant: the clip rect dimensions may appear large (10px × 10px), suggesting a visible region, but the position places the region entirely off-screen from the text. A scanner checking only clip rect dimensions without checking positional overlap with the clipped element will miss this pattern. Detection requires computing the intersection of the clip rect (in SVG user-unit space) with the text element’s geometry (also in SVG user-unit space) and flagging zero-intersection clip paths on consent text.
<!-- SA-CSS-CPU-002: clipPathUnits="userSpaceOnUse" (default); clipRect at (-5000,-5000) -->
<!-- clip rect is a 10×10 region at SVG coordinates (-5000,-5000) -->
<!-- text element at approx (20,37)-(220,53); clip rect does not intersect text; text invisible -->
<!-- clip rect width=10 height=10 looks reasonable; position makes it entirely off-element -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- userSpaceOnUse (default): coordinates in SVG viewport user-unit space -->
<!-- rect at x=-5000 y=-5000: far outside SVG viewport; no intersection with text -->
<clipPath id="clip-cpu-002">
<rect x="-5000" y="-5000" width="10" height="10"/>
</clipPath>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- clip rect at (-5000,-5000) has zero intersection with text at (~20,37); text invisible -->
<text x="20" y="50" font-size="13" fill="#111"
clip-path="url(#clip-cpu-002)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: compute intersection of clip rect (user-unit space) with text element BCR
function checkClipRectIntersection(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
const svgBcr = svgEl.getBoundingClientRect();
const svgVB = svgEl.viewBox?.baseVal;
function toPixels(userUnit, svgDimPx, vbDim) {
if (!svgVB || vbDim === 0) return userUnit;
return userUnit * (svgDimPx / vbDim);
}
for (const el of svgEl.querySelectorAll('[clip-path]')) {
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const idMatch = (el.getAttribute('clip-path') ?? '').match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const clipPathEl = document.getElementById(idMatch[1]);
if (!clipPathEl) continue;
const units = clipPathEl.getAttribute('clipPathUnits') || 'userSpaceOnUse';
if (units !== 'userSpaceOnUse') return; // handled by CPU-001
for (const shape of clipPathEl.querySelectorAll('rect')) {
const cx = parseFloat(shape.getAttribute('x') ?? '0');
const cy = parseFloat(shape.getAttribute('y') ?? '0');
const cw = parseFloat(shape.getAttribute('width') ?? '0');
const ch = parseFloat(shape.getAttribute('height') ?? '0');
const elBcr = el.getBoundingClientRect();
// Convert element BCR to SVG user-unit space
const scaleX = svgVB ? svgVB.width / svgBcr.width : 1;
const scaleY = svgVB ? svgVB.height / svgBcr.height : 1;
const elUULeft = (elBcr.left - svgBcr.left) * scaleX;
const elUUTop = (elBcr.top - svgBcr.top) * scaleY;
const elUURight = elUULeft + elBcr.width * scaleX;
const elUUBottom = elUUTop + elBcr.height * scaleY;
// Check intersection of clip rect with element bounding box
const intersectW = Math.max(0, Math.min(cx + cw, elUURight) - Math.max(cx, elUULeft));
const intersectH = Math.max(0, Math.min(cy + ch, elUUBottom) - Math.max(cy, elUUTop));
const intersectArea = intersectW * intersectH;
const elementArea = (elUURight - elUULeft) * (elUUBottom - elUUTop);
const visibleFrac = elementArea > 0 ? intersectArea / elementArea : 0;
if (visibleFrac < 0.01) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-CPU-002',
note: `clipPathUnits="userSpaceOnUse"; clipRect x=${cx} y=${cy} w=${cw} h=${ch}; ` +
`element BCR in user-units: (${elUULeft.toFixed(0)},${elUUTop.toFixed(0)}) to ` +
`(${elUURight.toFixed(0)},${elUUBottom.toFixed(0)}); ` +
`clip-element intersection area=${intersectArea.toFixed(2)} uu²; ` +
`element area=${elementArea.toFixed(2)} uu²; visible=${(visibleFrac*100).toFixed(4)}%; ` +
`clip rect positioned off element: zero intersection → text invisible; ` +
`clipRect dimensions (${cw}×${ch}) appear plausible; position hides deception; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero`
});
}
}
}
return findings;
}
CRITICAL — SA-CSS-CPU-002: A clipPath with userSpaceOnUse (default) and a clip rect positioned at (−5000,−5000) has zero intersection with a text element at (20,37). The clip rect dimensions (10×10 SVG units) appear plausible — a dimension-only check would not flag this — but the position ensures no clipping region overlaps the text. Detection must compute the intersection area of the clip rect (in SVG user-unit coordinates) with the clipped element’s geometry and flag near-zero intersection fractions.
Attack 3: empty <clipPath> (no shapes inside) — SVG spec clips entire element to empty (SA-CSS-CPU-003)
The SVG specification states that when a clipPath element contains no valid child shapes, the clip region is considered to be empty — and an empty clip region clips the entire element to nothing (all pixels masked out). An attacker references a <clipPath> with no child elements, or with child elements that compute to zero area (e.g., a circle with r="0"), effectively hiding the entire consent text element. The clip-path attribute is present in the DOM; there is no visible geometric indication of the clip; the textContent is intact.
A scanner checking for the presence of a clip-path attribute and then examining the referenced <clipPath> must also verify that the clipPath contains at least one child shape with non-zero area. An empty <clipPath> is a degenerate case that some scanners overlook because the clip-path reference check passes and the children-count check is skipped.
<!-- SA-CSS-CPU-003: empty <clipPath> — no child shapes → clip region = empty → entire element clipped -->
<!-- clip-path="url(#clip-cpu-003)" present; clipPath has no children; entire text invisible -->
<!-- Variant: circle r="0" also produces empty clip region -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- empty clipPath: no child shapes → clip region = ∅ → element entirely clipped to nothing -->
<clipPath id="clip-cpu-003">
<!-- intentionally empty -->
</clipPath>
<!-- alternative: zero-radius circle also = empty clip region -->
<clipPath id="clip-cpu-003b">
<circle cx="20" cy="50" r="0"/>
</clipPath>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- clip-path references empty clipPath → text entirely invisible -->
<text x="20" y="50" font-size="13" fill="#111"
clip-path="url(#clip-cpu-003)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: check clipPath has non-zero-area children
function checkEmptyClipPath(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const el of svgEl.querySelectorAll('[clip-path]')) {
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const idMatch = (el.getAttribute('clip-path') ?? '').match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const clipPathEl = document.getElementById(idMatch[1]);
if (!clipPathEl) continue;
const shapes = Array.from(clipPathEl.querySelectorAll('rect, circle, ellipse, path, polygon'));
if (shapes.length === 0) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-CPU-003',
note: `clip-path="url(#${idMatch[1]})"; referenced <clipPath> has NO child shapes; ` +
`SVG spec: empty clipPath clips element to nothing (clip region = ∅); ` +
`entire consent text element invisible; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero; ` +
`clip-path attribute present: presence-check passes; ` +
`detection: verify clipPath has ≥1 child shape with non-zero area`
});
} else {
// Check for zero-area shapes (r=0 circle, width=0 rect, etc.)
const zeroAreaShapes = shapes.filter(s => {
if (s.tagName === 'circle') return parseFloat(s.getAttribute('r') ?? '1') === 0;
if (s.tagName === 'rect') return parseFloat(s.getAttribute('width') ?? '1') === 0 ||
parseFloat(s.getAttribute('height') ?? '1') === 0;
if (s.tagName === 'ellipse') return parseFloat(s.getAttribute('rx') ?? '1') === 0 ||
parseFloat(s.getAttribute('ry') ?? '1') === 0;
return false;
});
if (zeroAreaShapes.length === shapes.length) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-CPU-003b',
note: `clip-path="url(#${idMatch[1]})"; all ${shapes.length} child shapes have zero area; ` +
`(e.g., circle r=0, rect width=0); clip region effectively empty; ` +
`entire consent text clipped to nothing; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero`
});
}
}
}
return findings;
}
CRITICAL — SA-CSS-CPU-003: An empty <clipPath> (no child shapes) causes the SVG renderer to clip the entire element to nothing per the SVG specification. A scanner that checks for the presence of a clip-path attribute and confirms the referenced ID exists, but does not check whether the <clipPath> has any non-zero-area child shapes, will miss this. Detection must verify that the referenced <clipPath> contains at least one child shape and that each shape has non-zero dimensions.
Attack 4: JS mousedown swaps clip rect to zero dimensions, reverts at mouseup (SA-CSS-CPU-004)
The dynamic pattern maintains a plausible clip rect at rest (e.g., width="400" height="100" — large enough to show all text) and swaps it to width="0.001" height="0.001" (in objectBoundingBox units) at mousedown. The clip region collapses to sub-pixel immediately before the click registers. At mouseup, the clip rect is restored. Static inspection finds the plausible large clip rect and concludes the text is visible. Detection requires a MutationObserver watching the clip rect’s width and height attributes, combined with a synthetic mousedown to trigger the swap.
<!-- SA-CSS-CPU-004: at rest clip rect width=400 height=100 (large, text visible) -->
<!-- mousedown: setAttribute width=0.001 height=0.001 (objectBoundingBox → sub-pixel) -->
<!-- text invisible during click; mouseup: restored; static clip looks valid -->
<svg id="consent-svg-cpu" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="clip-cpu-004" clipPathUnits="objectBoundingBox">
<rect id="clip-rect-cpu" x="0" y="0" width="400" height="100"/>
</clipPath>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- at rest: clip rect width=400 height=100 in objectBoundingBox → clips ≫100% → shows all -->
<!-- mousedown: width=0.001 height=0.001 → 0.01% visible → sub-pixel → invisible -->
<text x="20" y="50" font-size="13" fill="#111"
clip-path="url(#clip-cpu-004)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg-cpu');
const rect = document.getElementById('clip-rect-cpu');
svg.addEventListener('mousedown', () => {
rect.setAttribute('width', '0.001'); // objectBoundingBox: 0.1% of element width
rect.setAttribute('height', '0.001'); // objectBoundingBox: 0.1% of element height
}, true);
svg.addEventListener('mouseup', () => {
rect.setAttribute('width', '400'); // restore
rect.setAttribute('height', '100');
}, true);
})();
</script>
// Detection: MutationObserver on clip rect dimensions + synthetic mousedown
function watchClipRectDimensionSwap(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (!['width','height'].includes(mut.attributeName)) continue;
const el = mut.target;
if (el.parentElement?.tagName !== 'clipPath') continue;
const w = parseFloat(el.getAttribute('width') ?? '1') || 0;
const h = parseFloat(el.getAttribute('height') ?? '1') || 0;
const units = el.parentElement.getAttribute('clipPathUnits') || 'userSpaceOnUse';
let areaFrac = (units === 'objectBoundingBox') ? w * h : null;
if (areaFrac !== null && areaFrac < 0.01) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-CPU-004',
note: `Dynamic clip rect mutation: ${mut.attributeName}="${el.getAttribute(mut.attributeName)}" ` +
`(was "${mut.oldValue}"); clipPathUnits="${units}"; ` +
`new clip area fraction=${(areaFrac*100).toFixed(4)}% (< 1% threshold); ` +
`consent text invisible during click window; ` +
`at rest dimensions appear valid; static check PASSES; ` +
`MutationObserver attributeFilter=['width','height'] on clipPath children required`
});
}
}
});
for (const cpEl of svgEl.querySelectorAll('clipPath')) {
for (const child of cpEl.children) {
observer.observe(child, { attributes: true, attributeFilter: ['width','height','r','rx','ry'], attributeOldValue: true });
}
}
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-CPU-004: The clip rect’s dimensions are reduced to 0.001×0.001 (objectBoundingBox) at mousedown, collapsing the visible clip area to 0.01% of the text element. At rest the dimensions are large (400×100) and static inspection concludes the text is fully visible. Detection requires a MutationObserver on clip rect shape attributes (width, height, r) combined with a synthetic mousedown dispatch and a post-mutation clip-area-fraction check.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-CPU-001: objectBoundingBox tiny rect (width=0.001 height=0.001) | Clip coordinates in 0-1 fractions of element BCR; 0.001×0.001 = 0.01% of element area; sub-pixel visible region; text invisible; textContent intact; BCR non-zero | Dimension-only scanner (sees 0.001, interprets as user units not fractions); requires: detect clipPathUnits=objectBoundingBox; compute area fraction = w*h; flag <1% | Critical |
| SA-CSS-CPU-002: userSpaceOnUse clip rect at (-5000,-5000) — zero intersection | Clip rect dimensions appear plausible (10×10 user units); position at (-5000,-5000) has zero intersection with text element at (20,37); text entirely invisible | Dimension-only scanner (10×10 looks valid); requires: compute intersection of clip rect with element bounding box; flag zero-intersection | Critical |
| SA-CSS-CPU-003: empty clipPath — no children → clip region = ∅ → entire element invisible | No child shapes in <clipPath>; SVG spec: empty clip = clip everything; clip-path attr present; reference valid; children absent | clip-path presence check; reference validity check; requires: verify clipPath has ≥1 non-zero-area child shape | Critical |
| SA-CSS-CPU-004: mousedown swaps clip rect to 0.001×0.001; reverts mouseup | At rest: clip rect 400×100 (large); mousedown: 0.001×0.001 (objectBoundingBox 0.01%); text invisible during click; mouseup: restored; static check passes | Static DOM; requires: MutationObserver on clip rect dimensions + synthetic mousedown + clip-area-fraction check post-mutation | Critical |