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

AttackMechanismWhat it bypassesSeverity
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

SkillAudit findings for this attack surface

CRITICAL SA-CSS-MKR-001: <marker> element contains cover-colored shape (rect/circle/path); marker referenced by line/path/polyline element over consent area; getBoundingClientRect() on parent element does not include marker bounds; marker reference resolution and marker content inspection required.
CRITICAL SA-CSS-MKR-002: Line or path element with near-zero BCR (zero-length or degenerate geometry) references a large marker — marker renders cover-sized shape at endpoint outside parent BCR; BCR-based overlap check returns zero-area intersection; zero-BCR elements with large marker references must be flagged.
CRITICAL SA-CSS-MKR-003: <marker markerUnits="userSpaceOnUse"> with large markerWidth/markerHeight covers significant portion of SVG viewport — userSpaceOnUse dimensions are absolute in SVG user units; strokeWidth-relative interpretation underestimates marker size; effective pixel dimensions must be computed accounting for markerUnits before evaluating cover potential.
CRITICAL SA-CSS-MKR-004: Line/path element marker-start, marker-mid, or marker-end attribute added dynamically during consent window — marker cover injected at mousedown; reverts at mouseup; static DOM shows no marker attribute before/after; MutationObserver on marker attributes of all path-type elements in consent SVG required.

Related: SVG use href server-update consent attacks  |  Dynamic and temporal SVG consent hijack synthesis  |  SVG mask element consent attacks

← Blog  |  Security Checklist