Security Guide
MCP server SVG <marker> element consent security — marker cover rect BCR mismatch, zero-length line marker, marker-units="userSpaceOnUse" large cover, and mousedown marker injection
The SVG <marker> element defines graphical content placed at path endpoints or midpoints via the marker-start, marker-mid, and marker-end presentation attributes. Marker content renders in a separate coordinate space positioned relative to the path geometry — not the element’s bounding box — and the getBoundingClientRect() of the parent element does not include the marker’s rendered bounds. This coordinate space isolation makes marker-based cover attacks invisible to BCR-based visibility checks.
Attack 1: Cover rect in <marker> referenced by transparent line — BCR mismatch (SA-CSS-MKR-001)
A <marker> element in the SVG <defs> contains a white cover rectangle sized to cover the consent panel (400×120px). A transparent <line> element is positioned with its endpoint at the center of the consent panel and references the marker via marker-end="url(#cover)". The marker renders the white rectangle at the line’s endpoint position, visually covering the consent text. However, getBoundingClientRect() on the <line> element returns the bounding box of the line geometry only — it does not include the marker’s rendered area. For a line from (0, 0) to (200, 60), the BCR is a thin diagonal line’s bounds, not the 400×120 cover rect. An overlap check using BCR of line elements misses the marker coverage entirely. The cover is only detectable by resolving the marker-end URL reference, fetching the marker definition, and analyzing the marker content for cover shapes.
<!-- SA-CSS-MKR-001: cover rect in marker referenced by transparent line; BCR mismatches -->
<svg width="400" height="200">
<defs>
<marker id="cover" markerWidth="400" markerHeight="120"
refX="200" refY="60" markerUnits="userSpaceOnUse">
<!-- White cover rect -- renders at marker position but not in parent line's BCR -->
<rect x="-200" y="-60" width="400" height="120" fill="white"/>
</marker>
</defs>
<!-- Consent text -- covered by marker rect above -->
<text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
<text x="20" y="70" font-size="13">[Allow] [Deny]</text>
<!-- Transparent line with marker -- placed over consent area -->
<!-- BCR of this line: roughly (0,0,400,120) thin diagonal; marker not included -->
<line x1="200" y1="60" x2="200" y2="60.001"
stroke="transparent" stroke-width="0"
marker-end="url(#cover)"/>
</svg>
// Detection: resolve marker-start/marker-mid/marker-end references and check for cover shapes
function checkMarkerReferences(svgEl) {
const findings = [];
const markerAttrs = ['marker-start', 'marker-mid', 'marker-end',
'markerStart', 'markerMid', 'markerEnd'];
// Find all elements with marker references
const markerEls = Array.from(svgEl.querySelectorAll('*')).filter(el =>
markerAttrs.some(attr => el.hasAttribute(attr)) ||
markerAttrs.some(attr =>
(getComputedStyle(el)[attr] || '').startsWith('url('))
);
for (const el of markerEls) {
for (const attr of markerAttrs) {
const val = el.getAttribute(attr) ||
(getComputedStyle(el)[attr] || '');
if (!val || !val.startsWith('url(')) continue;
const markerId = val.match(/url\(#([^)]+)\)/)?.[1];
if (!markerId) continue;
const markerDef = svgEl.getElementById(markerId);
if (!markerDef) continue;
// Check marker content for cover shapes
const coverShapes = markerDef.querySelectorAll('rect, circle, ellipse, path');
for (const shape of coverShapes) {
const fill = shape.getAttribute('fill') ||
getComputedStyle(shape).fill || '';
if (isCoverColor(fill)) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-MKR-001',
note: `<marker id="${markerId}"> contains ${shape.tagName} with fill="${fill}" — ` +
`cover shape in marker referenced by ${el.tagName} element via ${attr}="${val}"; ` +
`getBoundingClientRect() on ${el.tagName} does not include marker bounds; ` +
`BCR-based overlap check misses marker cover; marker reference resolution required`
});
}
}
}
}
return findings;
}
CRITICAL — SA-CSS-MKR-001: A white cover rectangle inside a <marker> element referenced by a transparent line positioned over consent text produces a visual cover that is invisible to getBoundingClientRect()-based overlap detection. The parent line’s BCR reflects the line geometry, not the marker’s rendered bounds. Detection must resolve marker-start, marker-mid, and marker-end URL references, fetch the marker definition, and check all shapes within the marker for cover fill colors.
Attack 2: Zero-length line with large <marker> cover rect — degenerate BCR (SA-CSS-MKR-002)
A <line> element with identical start and end coordinates (x1="200" y1="60" x2="200" y2="60") has zero-length geometry and a degenerate bounding box (zero width and height). Its getBoundingClientRect() returns a rect with zero area. Overlap detection algorithms that check whether an overlay element’s BCR intersects the consent text BCR find no intersection for a zero-BCR element. However, the marker-end attribute places the marker at the line endpoint (200, 60), and the marker contains a large 400×120 white rectangle. The marker renders visually at (200, 60) covering the consent panel. The SVG rendering model allows markers to render with overflow: visible beyond the parent element’s own geometry. The combination of zero-BCR parent and large marker content means: no overlap detection fires, no element in the DOM has a BCR matching the cover, and the cover is present only in the rendered output. To detect this pattern, the audit must find all zero-dimension or near-zero-dimension elements with marker attributes and inspect their referenced markers.
<!-- SA-CSS-MKR-002: zero-length line with large marker cover rect -- zero BCR on parent -->
<svg width="400" height="200">
<defs>
<marker id="panel-cover" markerWidth="400" markerHeight="120"
refX="0" refY="0" markerUnits="userSpaceOnUse">
<!-- Large cover rect -- 400x120 covers entire consent panel -->
<rect width="400" height="120" fill="white"/>
</marker>
</defs>
<text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
<text x="20" y="70" font-size="13">[Allow] [Deny]</text>
<!-- Zero-length line: BCR = {x:200, y:60, width:0, height:0} -- zero-area overlap check passes -->
<!-- Marker renders 400x120 white rect at the line endpoint position -->
<line x1="0" y1="0" x2="0" y2="0"
stroke="none"
marker-end="url(#panel-cover)"/>
</svg>
// Detection: flag zero-dimension elements with marker references
function checkZeroDimensionMarkers(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('line, path, polyline, polygon')) {
const hasMarker = el.getAttribute('marker-start') ||
el.getAttribute('marker-mid') ||
el.getAttribute('marker-end');
if (!hasMarker) continue;
const bcr = el.getBoundingClientRect();
const isZeroOrTiny = bcr.width < 2 && bcr.height < 2;
if (isZeroOrTiny) {
// Zero or near-zero BCR with marker reference -- suspicious
const markerRefs = [
el.getAttribute('marker-start'),
el.getAttribute('marker-mid'),
el.getAttribute('marker-end')
].filter(Boolean);
for (const ref of markerRefs) {
const markerId = ref.match(/url\(#([^)]+)\)/)?.[1];
const markerDef = markerId ? svgEl.getElementById(markerId) : null;
if (markerDef) {
const mWidth = parseFloat(markerDef.getAttribute('markerWidth') || '3');
const mHeight = parseFloat(markerDef.getAttribute('markerHeight') || '3');
const mUnits = markerDef.getAttribute('markerUnits') || 'strokeWidth';
// Large marker (in userSpaceOnUse) or large relative marker
const isLargeMarker = mUnits === 'userSpaceOnUse'
? mWidth > 50 || mHeight > 50
: mWidth > 10 || mHeight > 10;
if (isLargeMarker) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-MKR-002',
note: `${el.tagName} element has BCR width=${bcr.width.toFixed(1)} height=${bcr.height.toFixed(1)} ` +
`(zero/degenerate) but references large marker (id="${markerId}", ` +
`${mWidth}x${mHeight} ${mUnits}) — marker may render cover shape outside ` +
`parent element's BCR; BCR-based overlap detection returns zero area; ` +
`marker content must be inspected for cover shapes`
});
}
}
}
}
}
return findings;
}
CRITICAL — SA-CSS-MKR-002: A zero-length <line> element has a degenerate getBoundingClientRect() with zero area. Overlap detection algorithms find no intersection between a zero-BCR element and the consent text BCR. However, a large <marker> referenced by the line renders at the line’s endpoint position with its own coordinate space, potentially covering the entire consent panel. Detection must identify elements with near-zero BCR that reference marker definitions containing large shapes.
Attack 3: marker-units="userSpaceOnUse" with large absolute cover rect (SA-CSS-MKR-003)
The markerUnits attribute controls the coordinate system for the marker’s markerWidth, markerHeight, and internal content. With the default markerUnits="strokeWidth", marker dimensions are multiples of the referencing element’s stroke width. A 1 strokeWidth on a 1px-stroke line is 1px. But with markerUnits="userSpaceOnUse", all marker dimensions are in SVG user units (pixels at default scale). A marker with markerWidth="400" markerHeight="120" markerUnits="userSpaceOnUse" and an internal <rect width="400" height="120"> renders a 400×120 pixel cover rectangle. This is independent of the referencing element’s stroke width. An auditor who sees a marker with markerWidth="400" and reads it as “400 stroke-width units” (which would be 400×stroke-width px — alarming for thick strokes, innocuous for thin ones) misses the actual pixel dimensions. With userSpaceOnUse, markerWidth="400" is 400 SVG user units which, at the default SVG scale and typical MCP consent panel sizes, exactly covers the panel. The markerUnits attribute must be checked when evaluating marker dimensions.
<!-- SA-CSS-MKR-003: markerUnits=userSpaceOnUse makes marker 400x120 SVG user units -->
<svg width="400" height="200" viewBox="0 0 400 200">
<defs>
<!-- markerUnits=userSpaceOnUse: markerWidth/Height in SVG user units -->
<!-- A 400x120 marker at refX=0 refY=0 covers the entire consent panel area -->
<marker id="abs-cover"
markerWidth="400" markerHeight="120"
refX="0" refY="0"
markerUnits="userSpaceOnUse">
<!-- Internal rect in userSpaceOnUse coordinates: 400x120 pixels -->
<rect width="400" height="120" fill="white"/>
</marker>
</defs>
<text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
<text x="20" y="70" font-size="13">[Allow] [Deny]</text>
<!-- Short line at (0,0): marker-end renders 400x120 cover at (0,0) -->
<line x1="0" y1="0" x2="1" y2="0"
stroke="transparent"
marker-end="url(#abs-cover)"/>
</svg>
// Detection: compute effective marker pixel dimensions including markerUnits
function getMarkerEffectiveSize(markerEl, svgEl, referencingEl) {
const markerWidth = parseFloat(markerEl.getAttribute('markerWidth') || '3');
const markerHeight = parseFloat(markerEl.getAttribute('markerHeight') || '3');
const markerUnits = markerEl.getAttribute('markerUnits') || 'strokeWidth';
let effectiveWidthPx, effectiveHeightPx;
if (markerUnits === 'userSpaceOnUse') {
// Dimensions in SVG user units -- multiply by SVG viewport scale
const svgViewBox = svgEl.viewBox?.baseVal;
const svgBCR = svgEl.getBoundingClientRect();
const scaleX = svgViewBox?.width > 0
? svgBCR.width / svgViewBox.width : 1;
const scaleY = svgViewBox?.height > 0
? svgBCR.height / svgViewBox.height : 1;
effectiveWidthPx = markerWidth * scaleX;
effectiveHeightPx = markerHeight * scaleY;
} else {
// strokeWidth units: multiply by element's stroke width
const strokeWidth = parseFloat(
getComputedStyle(referencingEl).strokeWidth || '1'
);
effectiveWidthPx = markerWidth * strokeWidth;
effectiveHeightPx = markerHeight * strokeWidth;
}
return { effectiveWidthPx, effectiveHeightPx, markerUnits };
}
function checkLargeUserSpaceMarkers(svgEl) {
const findings = [];
const svgBCR = svgEl.getBoundingClientRect();
for (const marker of svgEl.querySelectorAll('marker')) {
const markerUnits = marker.getAttribute('markerUnits') || 'strokeWidth';
if (markerUnits !== 'userSpaceOnUse') continue;
// Find referencing elements
const markerId = marker.getAttribute('id');
const refEls = svgEl.querySelectorAll(
`[marker-start="url(#${markerId})"], ` +
`[marker-mid="url(#${markerId})"], ` +
`[marker-end="url(#${markerId})"]`
);
if (!refEls.length) continue;
const firstRef = refEls[0];
const { effectiveWidthPx, effectiveHeightPx } = getMarkerEffectiveSize(
marker, svgEl, firstRef
);
// If marker effective size covers more than 50% of SVG viewport
const coversLargeArea = effectiveWidthPx > svgBCR.width * 0.5 ||
effectiveHeightPx > svgBCR.height * 0.5;
if (coversLargeArea) {
// Check for cover-colored shapes inside
const shapes = marker.querySelectorAll('rect, circle, ellipse, path');
for (const shape of shapes) {
const fill = shape.getAttribute('fill') || getComputedStyle(shape).fill;
if (isCoverColor(fill)) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-MKR-003',
note: `<marker id="${markerId}" markerUnits="userSpaceOnUse"> contains ` +
`${shape.tagName} fill="${fill}"; effective marker size ` +
`${effectiveWidthPx.toFixed(0)}x${effectiveHeightPx.toFixed(0)}px ` +
`covers ${(effectiveWidthPx/svgBCR.width*100).toFixed(0)}% of SVG viewport width; ` +
`markerUnits=userSpaceOnUse makes dimensions absolute in SVG user units, ` +
`not stroke-width multiples; cover extent may be misread as strokeWidth-relative`
});
}
}
}
}
return findings;
}
CRITICAL — SA-CSS-MKR-003: markerUnits="userSpaceOnUse" makes marker dimensions absolute in SVG user units rather than stroke-width multiples. A markerWidth="400" in userSpaceOnUse is 400 SVG user units (typically 400px at default scale) — potentially covering the entire consent panel — while the same value in the default strokeWidth mode would be 400×stroke-width (as little as 400px for 1px strokes, but linearly scaled with stroke). Detection must compute effective marker pixel dimensions by checking markerUnits and applying the appropriate scale factor.
Attack 4: mousedown injects marker-end attribute during click window (SA-CSS-MKR-004)
A transparent <line> element positioned over the consent panel has no marker-end attribute at consent display time. The SVG <defs> contains a pre-defined <marker id="cover"> with a white cover rectangle. A JavaScript mousedown listener fires before the click event and calls lineEl.setAttribute('marker-end', 'url(#cover)'). The cover marker renders at the line’s endpoint, covering the consent text. A mouseup listener removes the attribute: lineEl.removeAttribute('marker-end'). The static DOM before the click shows the line element with no marker-end attribute. The static DOM after the click also shows no marker-end attribute. The cover exists only during the 50–200ms window between mousedown and mouseup. This follows the same temporal attack pattern as other mousedown injection attacks (SA-CSS-STP-001, SA-CSS-CPE-004, SA-CSS-MSK-004) but applied to the marker reference attribute. A MutationObserver watching marker-start, marker-mid, and marker-end attribute additions on all line, path, and polyline elements within the consent SVG is required for detection.
<!-- SA-CSS-MKR-004: mousedown injects marker-end attribute; removes at mouseup -->
<svg width="400" height="200">
<defs>
<!-- Pre-defined cover marker -- not referenced at rest -->
<marker id="cover" markerWidth="400" markerHeight="120"
refX="0" refY="0" markerUnits="userSpaceOnUse">
<rect width="400" height="120" fill="white"/>
</marker>
</defs>
<text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
<text x="20" y="70" font-size="13">[Allow] [Deny]</text>
<!-- No marker-end at rest -- marker injected at mousedown -->
<line id="cover-line" x1="0" y1="0" x2="1" y2="0" stroke="transparent"/>
</svg>
<script>
(function() {
const coverLine = document.getElementById('cover-line');
document.addEventListener('mousedown', () => {
coverLine.setAttribute('marker-end', 'url(#cover)');
}, true);
document.addEventListener('mouseup', () => {
coverLine.removeAttribute('marker-end');
}, true);
})();
</script>
// Detection: MutationObserver on marker-start/mid/end attribute changes
function monitorMarkerInjection(svgEl, onFinding) {
const pathElements = svgEl.querySelectorAll('line, path, polyline, polygon');
for (const el of pathElements) {
const obs = new MutationObserver(mutations => {
for (const m of mutations) {
if (['marker-start', 'marker-mid', 'marker-end',
'markerStart', 'markerMid', 'markerEnd'].includes(m.attributeName)) {
const newVal = el.getAttribute(m.attributeName);
if (!newVal) continue; // attribute removed -- monitor for re-injection
// Resolve marker and check for cover shape
const markerId = newVal.match(/url\(#([^)]+)\)/)?.[1];
const markerDef = markerId ? svgEl.getElementById(markerId) : null;
let hasCoverShape = false;
if (markerDef) {
for (const shape of markerDef.querySelectorAll('rect, circle, ellipse, path')) {
const fill = shape.getAttribute('fill') || getComputedStyle(shape).fill;
if (isCoverColor(fill)) { hasCoverShape = true; break; }
}
}
onFinding({
severity: 'CRITICAL', code: 'SA-CSS-MKR-004',
note: `${el.tagName} element received ${m.attributeName}="${newVal}" dynamically ` +
`(was "${m.oldValue || 'unset'}") — marker injected during interaction window; ` +
`cover shape in marker: ${hasCoverShape ? 'YES' : 'no (but still suspicious)'}; ` +
`static DOM scan misses; MutationObserver on marker attributes required`
});
}
}
});
obs.observe(el, {
attributes: true,
attributeFilter: ['marker-start', 'marker-mid', 'marker-end'],
attributeOldValue: true
});
}
}
CRITICAL — SA-CSS-MKR-004: A mousedown handler that adds a marker-end attribute referencing a pre-defined cover marker injects a cover rectangle during the click window and removes it at mouseup. The static DOM before and after the click shows no marker reference. Detection requires a MutationObserver watching marker-start, marker-mid, and marker-end attribute additions on all line, path, and polyline elements within the consent SVG.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-MKR-001: marker cover rect BCR mismatch | Cover rect in <marker> referenced by transparent line over consent text; getBoundingClientRect() on parent line does not include marker bounds; BCR-based overlap check misses cover | BCR-based overlap detection (returns line geometry, not marker extent); requires marker reference resolution and marker content inspection for cover fill colors | Critical |
| SA-CSS-MKR-002: zero-length line with large marker | Zero-length line has degenerate BCR (zero area); marker renders 400x120 cover at endpoint; overlap check on zero-BCR element finds no intersection with consent text BCR | BCR-based overlap check (zero area line, no intersection); requires detecting zero-dimension elements with large marker references and inspecting marker shapes | Critical |
| SA-CSS-MKR-003: userSpaceOnUse large marker cover | markerUnits=userSpaceOnUse makes 400x120 marker cover 400px x 120px in SVG viewport; default strokeWidth-relative reading misinterprets marker size as tiny | Default strokeWidth-relative marker size interpretation; requires markerUnits check and effective pixel dimension calculation when markerUnits=userSpaceOnUse | Critical |
| SA-CSS-MKR-004: mousedown marker-end injection | JS mousedown adds marker-end attribute referencing cover marker; removes at mouseup; cover present only during 50-200ms click window; static DOM shows no marker reference before/after | Static DOM scan (no marker attribute before/after click); requires MutationObserver on marker-start/mid/end attributes of all path/line elements in consent SVG | Critical |