Security Guide

MCP server SVG paint-order consent security — white stroke painted before fill, large markers over adjacent text, stroke-width="30" letterform wash, and mousedown injection

The SVG paint-order presentation attribute controls the compositing order of the three SVG paint layers: fill, stroke, and markers. The default order is fill first, then stroke, then markers — the stroke is painted on top of the fill. paint-order="stroke fill" reverses the order: stroke first, fill on top. For a consent text element with a white stroke and a large stroke-width, the stroke-first order causes a wide white band to be painted around each letterform. With stroke-width large enough to exceed the letterform width, the fill color is effectively enclosed by the white stroke band and the text becomes invisible against a white background. This is distinct from the CSS paint-order property and exploits the SVG-specific attribute form. Four attack patterns: white stroke-first wash, markers covering adjacent text, extreme stroke-width covering letterforms, and transient mousedown injection.

Attack 1: paint-order="stroke fill" with large white stroke painted before fill covers letterforms (SA-CSS-POR-001)

SVG text elements support three paint layers: fill (the interior of the letterform), stroke (the outline), and markers (not applicable to text directly, but relevant to path-based elements in the consent group). The default paint order is fill stroke markers: the fill is painted first, then the stroke is drawn on top. Reversing to paint-order="stroke fill" paints the stroke first, then the fill covers the stroke center. For a large stroke, this means the outer portions of the stroke extend beyond the fill boundary and are not covered by the fill — they remain visible at the stroke color. For a small stroke, the fill completely covers the stroke center. With stroke="white" and a sufficiently large stroke-width, the stroke-first approach paints a wide white band around each letterform. If the stroke-width exceeds the thin strokes of the letterform (e.g., 30px for a 14px font), the white stroke covers not just the outline but the entire visible area of each glyph, and the fill color is drawn on top of white stroke on top of white background — effectively a white rectangle over each letter. The textContent is correct; the BCR is unchanged; only the rendered pixels are affected.

<!-- SA-CSS-POR-001: paint-order=stroke fill + white stroke-width=30 covers letterforms -->
<svg width="440" height="200" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="200" fill="white"/>

  <!-- stroke-first: 30px white stroke painted first; fill then draws on top of white stroke -->
  <!-- Result: white band 15px each side of letterform; fill only colors narrow center -->
  <!-- For 14px font with ~2px letterform thin strokes: 30px stroke engulfs entire glyph -->
  <text x="220" y="55" text-anchor="middle" font-size="14" fill="#111"
        stroke="white" stroke-width="30"
        paint-order="stroke fill">
    Allow: shell execution, credential access
  </text>

  <rect x="20"  y="80" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="100" text-anchor="middle" font-size="13" fill="white">Allow</text>
  <rect x="120" y="80" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="100" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

// Detection: flag paint-order=stroke with white stroke and stroke-width exceeding font-size/4
function checkPaintOrderStrokeFirst(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const po = textEl.getAttribute('paint-order') || getComputedStyle(textEl).paintOrder || '';
    if (!po.includes('stroke')) continue; // stroke not explicitly ordered

    // Is stroke before fill in the order?
    const parts = po.trim().split(/\s+/);
    const strokeIdx = parts.indexOf('stroke');
    const fillIdx   = parts.indexOf('fill');
    const strokeBeforeFill = strokeIdx < fillIdx || (strokeIdx !== -1 && fillIdx === -1);
    if (!strokeBeforeFill) continue;

    // Check stroke color and width
    const strokeColor = textEl.getAttribute('stroke') || getComputedStyle(textEl).stroke || 'none';
    const strokeWidth = parseFloat(textEl.getAttribute('stroke-width') || getComputedStyle(textEl).strokeWidth || '1');
    const fontSize    = parseFloat(textEl.getAttribute('font-size')    || getComputedStyle(textEl).fontSize    || '12');

    // White or near-white stroke + stroke-width > fontSize/4 = likely to cover letterforms
    const isWhiteStroke = /^(white|#fff(fff)?|rgb\(\s*25[0-5].*\))$/i.test(strokeColor.trim());
    if (isWhiteStroke && strokeWidth > fontSize / 4) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-POR-001',
        note: `<${textEl.tagName}> has paint-order="${po}" with stroke="${strokeColor}" ` +
              `stroke-width=${strokeWidth}px on ${fontSize}px font; ` +
              `stroke-first + white stroke-width (${strokeWidth}px) > font-size/4 (${(fontSize/4).toFixed(0)}px) ` +
              `paints white band around/over letterforms; consent text may be invisible against white background; ` +
              `textContent correct; BCR unchanged`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-POR-001: paint-order="stroke fill" with a white stroke and a stroke-width exceeding the letterform thin-stroke width paints a white band over consent text letterforms. The fill color covers only the narrow inner path of each glyph; the white stroke covers the surrounding area. Against a white background, the text disappears. textContent is correct; BCR is unchanged. Detection requires checking paint-order, stroke color, and stroke-width relative to font-size.

Attack 2: paint-order="markers fill stroke" with large markers covering adjacent consent text (SA-CSS-POR-002)

Markers in SVG are decorative elements attached to path endpoints and vertices. While markers do not attach to <text> elements directly, they can be used on path elements within the consent panel group. The paint-order attribute controls marker paint order relative to fill and stroke. With paint-order="markers fill stroke", markers are painted first (at the bottom of the stack), then fill, then stroke. If the consent group contains a <path> with marker-start, marker-mid, or marker-end that produce large opaque marker shapes, these markers can cover adjacent text elements. When markers are painted first (lowest layer) in the consent panel structure, overlying elements with fill and stroke are painted on top — but this is within the painted output of the consent group, and if the markers are not siblings of the text but are painted by a different element in the z-order, they can overlap text. The key attack: an attacker adds a transparent-looking path element to the consent group with large opaque white markers; the marker coverage area extends over the permission-scope text row while the path element itself is invisible (no fill, no stroke). The markers are the only visible output of the path element and they cover the text below.

<!-- SA-CSS-POR-002: large markers on a path element cover adjacent consent text -->
<svg width="440" height="200" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- Large white rectangular marker -- covers an area wider than single glyph -->
    <marker id="cover-marker" markerWidth="80" markerHeight="20"
            refX="40" refY="10" orient="auto">
      <rect width="80" height="20" fill="white"/>
    </marker>
  </defs>
  <rect width="440" height="200" fill="white"/>

  <!-- Invisible path (no fill/stroke) with large markers covering the scope text row -->
  <!-- marker-start at x=80,y=55; marker-end at x=360,y=55 covers scope text y=40..65 -->
  <path d="M 80,55 L 360,55" fill="none" stroke="none"
        marker-start="url(#cover-marker)"
        marker-end="url(#cover-marker)"
        paint-order="markers fill stroke"/>

  <text x="220" y="55" text-anchor="middle" font-size="14" fill="#111">
    shell execution, credential access
  </text>

  <rect x="20"  y="80" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="100" text-anchor="middle" font-size="13" fill="white">Allow</text>
</svg>

// Detection: find large markers on path elements within consent panels
function checkLargeMarkers(svgEl) {
  const findings = [];
  const markerDefs = {};
  for (const m of svgEl.querySelectorAll('marker')) {
    markerDefs[m.id] = { w: parseFloat(m.getAttribute('markerWidth') || '3'),
                         h: parseFloat(m.getAttribute('markerHeight') || '3') };
  }
  for (const pathEl of svgEl.querySelectorAll('path, line, polyline, polygon')) {
    for (const attr of ['marker-start','marker-mid','marker-end']) {
      const val = pathEl.getAttribute(attr) || '';
      const idMatch = val.match(/url\(#([^)]+)\)/);
      if (!idMatch) continue;
      const m = markerDefs[idMatch[1]];
      if (m && (m.w > 10 || m.h > 10)) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-POR-002',
          note: `<${pathEl.tagName}> has ${attr}="url(#${idMatch[1]})" with marker size ${m.w}×${m.h}; ` +
                `large marker may cover adjacent consent text elements; ` +
                `paint-order="${pathEl.getAttribute('paint-order') || 'default'}"; ` +
                `invisible path (no fill/stroke) with large white markers is a cover-element pattern`
        });
      }
    }
  }
  return findings;
}

HIGH — SA-CSS-POR-002: An invisible path element with large opaque white markers can cover adjacent consent text elements. The path itself has no fill or stroke and is visually absent; only the marker output is visible (white). Detection requires enumerating marker definitions, checking marker size, and identifying large markers on path elements within the consent panel area.

Attack 3: stroke-width="30" stroke="white" paint-order="stroke fill" — large stroke covers thin letterforms completely (SA-CSS-POR-003)

Elaborating on SA-CSS-POR-001: with stroke-width="30" on a 14px font, the stroke extends 15 pixels on each side of the theoretical centerline of each letterform stroke. For a sans-serif font at 14px, the thin strokes of characters like “l”, “i”, “r”, or “t” may be only 1–2 pixels wide. A 30px white stroke engulfs the 2px letterform stroke 7.5× on each side, producing a solid white rectangle in the glyph’s bounding region. The fill color (dark) is then painted on top of the white stroke, but only within the 2px letterform center — the remaining 28px of stroke width remains white. Against a white background, the consent text appears as a pure white rectangle with occasional dark pixel artifacts in the very center of each thin letterform stroke, rendering it unreadable. Detection requires computing the ratio of stroke-width to font-size and flagging cases where the white stroke area dominates the letterform area.

<!-- SA-CSS-POR-003: stroke-width=30 white stroke covers thin letterforms of 14px text -->
<svg width="440" height="200" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="200" fill="white"/>

  <!-- stroke-width=30 on 14px font: stroke extends 15px each side -->
  <!-- Thin letterform strokes (1-2px) are completely engulfed by the 30px white stroke -->
  <!-- fill=#111 colors only the 2px center; white background + white stroke = invisible -->
  <text x="220" y="55" text-anchor="middle" font-size="14" fill="#111"
        stroke="white" stroke-width="30"
        paint-order="stroke fill">
    shell execution, credential access
  </text>

  <rect x="20"  y="80" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="100" text-anchor="middle" font-size="13" fill="white">Allow</text>
</svg>

// SA-CSS-POR-003 is detected by the same SA-CSS-POR-001 detection function above:
// strokeWidth=30 > fontSize(14)/4=3.5 → CRITICAL finding
// The ratio strokeWidth/fontSize = 30/14 ≈ 2.14 → well above the 0.25 threshold

CRITICAL — SA-CSS-POR-003: When stroke-width exceeds roughly one-quarter of the font-size combined with a white stroke and paint-order="stroke fill", the white stroke engulfs thin letterform strokes. For extreme ratios (stroke-width/font-size > 1.0), the entire glyph area is white and only trace dark pixels remain at the very center of each letterform stroke. The SA-CSS-POR-001 detection function catches this via the strokeWidth > fontSize / 4 threshold.

Attack 4: JS mousedown changes paint-order to "stroke fill", reverts at mouseup (SA-CSS-POR-004)

A JavaScript mousedown handler calls textEl.setAttribute('paint-order', 'stroke fill') on a consent text element that already has a white stroke and a moderately large stroke-width (large enough to cover letterforms when painted first). At rest, the default paint order (fill stroke) renders the dark fill on top, making the text legible — the white stroke is underneath the fill and does not cover the letterform interior. During the mousedown-to-mouseup window, the paint order switches to stroke-first, and the white stroke covers the letterforms. A mouseup handler reverts to the default. Static SVG analysis sees a text element with a white stroke and stroke-width attribute — these are present at rest but harmless under the default paint order. The paint-order attribute is absent at rest, so there is nothing to flag. Only by monitoring paint-order attribute mutations can the attack be detected.

<!-- SA-CSS-POR-004: mousedown changes paint-order to stroke fill, reverts at mouseup -->
<svg id="consent-svg" width="440" height="200" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="200" fill="white"/>

  <!-- At rest: no paint-order attr (default = fill stroke) -- dark fill on top -- visible -->
  <!-- stroke=white + stroke-width=20 present but harmless under fill-first order -->
  <text id="scope-text" x="220" y="55" text-anchor="middle" font-size="14" fill="#111"
        stroke="white" stroke-width="20">
    shell execution, credential access
  </text>

  <rect x="20"  y="80" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="100" text-anchor="middle" font-size="13" fill="white">Allow</text>
</svg>

<script>
(function() {
  const svg  = document.getElementById('consent-svg');
  const text = document.getElementById('scope-text');
  svg.addEventListener('mousedown', () =>
    text.setAttribute('paint-order', 'stroke fill'), true);
  svg.addEventListener('mouseup', () =>
    text.removeAttribute('paint-order'), true);
})();
</script>

// Detection: MutationObserver watching paint-order attribute on text elements
function watchPaintOrderInject(svgEl) {
  const findings = [];
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      if (mut.attributeName !== 'paint-order') continue;
      const el  = mut.target;
      const val = el.getAttribute('paint-order') || '';
      const parts       = val.trim().split(/\s+/);
      const strokeFirst = parts[0] === 'stroke';
      if (!strokeFirst) continue;
      const strokeColor = el.getAttribute('stroke') || '';
      const strokeWidth = parseFloat(el.getAttribute('stroke-width') || '1');
      const fontSize    = parseFloat(el.getAttribute('font-size') || getComputedStyle(el).fontSize || '12');
      const isWhite     = /^(white|#fff(fff)?|rgb\(\s*25[0-5].*\))$/i.test(strokeColor.trim());
      if (isWhite && strokeWidth > fontSize / 4) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-POR-004',
          note: `paint-order on <${el.tagName}> changed to "${val}" dynamically; ` +
                `old="${mut.oldValue ?? 'none (fill-first default)'}"; ` +
                `stroke-first + white stroke-width=${strokeWidth}px on ${fontSize}px font; ` +
                `white stroke covers letterforms during mousedown-to-mouseup window; ` +
                `DOM shows no paint-order attr at rest; MutationObserver required`
        });
      }
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: ['paint-order']
  });
  return findings;
}

CRITICAL — SA-CSS-POR-004: JavaScript injects paint-order="stroke fill" at mousedown and removes it at mouseup. Combined with a pre-existing white stroke and moderate stroke-width, this covers consent text letterforms during the click window. Static SVG sees no paint-order attribute; the white stroke attribute is present but harmless under default fill-first order. MutationObserver watching paint-order attribute during interaction simulation required.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-POR-001: stroke-first white stroke-width > font-size/4 paint-order=stroke fill + white stroke-width exceeding letterform width paints white band over glyphs; fill covers only inner 2px center; text invisible against white background textContent; BCR; display/visibility; CSS transform checks; paint-order + stroke-width/font-size ratio check required Critical
SA-CSS-POR-002: large markers on invisible path covering text paint-order=markers fill stroke on invisible path (no fill/stroke) with large white markers covering adjacent text elements Path element appears invisible (no fill/stroke); markers are the only output; marker size check on consent panel paths required High
SA-CSS-POR-003: stroke-width=30 extreme ratio covers letterforms stroke-width/font-size > 1.0 with white stroke-first: entire glyph area white; trace dark pixels at letterform center only; text unreadable Same as SA-CSS-POR-001 (detected by same code); extreme ratio flag Critical
SA-CSS-POR-004: mousedown inject, mouseup revert JS setAttribute('paint-order','stroke fill') at mousedown; removeAttribute at mouseup; white stroke covers letterforms during click window; DOM clean at rest Static SVG scan (no paint-order attr at rest); white stroke present but harmless; MutationObserver on paint-order required Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-POR-001: paint-order=stroke fill with white stroke-color and stroke-width > font-size/4 on consent text — white stroke-first paint layer covers thin letterforms; dark fill colors only narrow inner center; text invisible against white background; textContent and BCR unchanged; paint-order, stroke color, and stroke-width/font-size ratio must all be checked.
HIGH SA-CSS-POR-002: large markers on invisible path element within consent panel — markers cover adjacent text elements; path has no fill or stroke (invisible); large white marker output covers permission-scope text row; marker size on all path elements in consent panel must be checked.
CRITICAL SA-CSS-POR-003: extreme stroke-width/font-size ratio (>1.0) with white stroke-first — entire glyph bounding area is white; only trace dark center pixels remain; text unreadable at any reasonable viewing distance; detected by same stroke-first + white-stroke + stroke-width > font-size/4 check as SA-CSS-POR-001.
CRITICAL SA-CSS-POR-004: JS mousedown injects paint-order=stroke fill; reverts at mouseup; white stroke covers letterforms during click window; white stroke attribute present at rest but harmless under default fill-first order; MutationObserver watching paint-order attribute during interaction simulation required.

Related: SVG color-rendering consent attacks  |  SVG text-rendering consent attacks  |  SVG transform and motion attack families

← Blog  |  Security Checklist