Security Guide

MCP server SVG spreadMethod consent security — repeat all-white short gradient tiles solid white fill, reflect sub-pixel stipple whiteout, pad outside-element gradient pads entire fill white, and mousedown spreadMethod swap

The SVG gradient spreadMethod attribute controls how a gradient extends beyond its defined coordinate range. Attackers exploit spreadMethod to produce solid-white or near-white fills across entire consent text elements while keeping the gradient definition looking syntactically legitimate: it has gradient stops, a defined coordinate range, and a recognized spreadMethod value. With spreadMethod="repeat", an all-white gradient defined in only 5% of the element width tiles across the full element. With spreadMethod="reflect", a 1%-cycle gradient creates alternating bands so narrow they appear solid white at normal zoom. With spreadMethod="pad" (the default), a gradient placed entirely outside the element (e.g., x1="1.5" x2="2.0" in objectBoundingBox) extends the first stop’s white color across the entire element via padding. All patterns leave textContent intact, BCR non-zero, and no CSS visibility property set.

Attack 1: spreadMethod="repeat" on a 5%-width all-white gradient tiles solid white across full element (SA-CSS-SPM-001)

A linearGradient is defined with x1="0" y1="0" x2="0.05" y2="0" in objectBoundingBox coordinates — the gradient runs from 0% to 5% of the element width. Both gradient stops are white: an offset="0" stop with stop-color="white" and an offset="1" stop with stop-color="white". With spreadMethod="repeat", the browser tiles this gradient pattern across the full element extent. Because every cycle of the pattern is the same all-white gradient, every pixel of the repeated fill is white. The element fill is therefore solid white, making the dark consent text invisible against the white background.

A scanner that only checks whether a single gradient stop is a non-white color misses this: there are no dark stops to find. A scanner checking whether all stops are the same uniform color correctly identifies a white-to-white gradient — but only if it checks stop colors within the defined gradient region (x1=0 to x2=0.05). The attack’s real bypass is in the combination: a short-range definition plus spreadMethod="repeat" stretching white tiles across the full element. Detection: check all stop colors in every gradient used on consent text; if all stops are high-brightness (R,G,B each ≥230), flag regardless of spreadMethod value — the entire element fill will be near-white by any spread method.

<!-- SA-CSS-SPM-001: spreadMethod="repeat"; gradient x1=0 x2=0.05 (5% of element width) -->
<!-- all stops white → repeat tiles solid white across full element -->
<!-- gradient has two stops (looks legitimate); spreadMethod appears intentional -->
<!-- scanner checking for dark stop misses; only all-stops-white check detects -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- linearGradient: x1=0 x2=0.05 (5% element width, objectBoundingBox default) -->
    <!-- spreadMethod=repeat: tiles the 5%-width white gradient across full element -->
    <!-- all stops white: offset=0 stop-color=white, offset=1 stop-color=white -->
    <!-- result: entire element fill is solid white -->
    <linearGradient id="grad-spm-001"
        gradientUnits="objectBoundingBox"
        x1="0" y1="0" x2="0.05" y2="0"
        spreadMethod="repeat">
      <stop offset="0"   stop-color="white" stop-opacity="1"/>
      <stop offset="1"   stop-color="white" stop-opacity="1"/>
    </linearGradient>
  </defs>
  <rect width="440" height="120" fill="white"/>

  <!-- fill=url(#grad-spm-001): solid white; consent text invisible -->
  <!-- textContent intact; BCR non-zero; no visibility/opacity property set -->
  <text x="20" y="50" font-size="13"
        fill="url(#grad-spm-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: check all gradient stop colors; flag if all stops are high-brightness
function checkSpreadMethodRepeat(svgEl) {
  const findings = [];
  const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];

  function parseColorBrightness(colorStr) {
    // Normalize to RGB and return minimum channel value
    colorStr = colorStr.trim().toLowerCase();
    if (colorStr === 'white' || colorStr === '#fff' || colorStr === '#ffffff') return 255;
    const hexMatch = colorStr.match(/^#([0-9a-f]{3,6})$/);
    if (hexMatch) {
      const h = hexMatch[1];
      if (h.length === 3) {
        return Math.min(
          parseInt(h[0]+h[0], 16),
          parseInt(h[1]+h[1], 16),
          parseInt(h[2]+h[2], 16)
        );
      }
      return Math.min(
        parseInt(h.slice(0,2), 16),
        parseInt(h.slice(2,4), 16),
        parseInt(h.slice(4,6), 16)
      );
    }
    const rgbMatch = colorStr.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
    if (rgbMatch) return Math.min(+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]);
    return -1; // unknown
  }

  for (const el of svgEl.querySelectorAll('[fill]')) {
    const text = el.textContent?.trim() ?? '';
    if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;

    const fillAttr = el.getAttribute('fill') ?? '';
    const idMatch = fillAttr.match(/url\(#([^)]+)\)/);
    if (!idMatch) continue;

    const gradEl = document.getElementById(idMatch[1]);
    if (!gradEl) continue;

    const spreadMethod = gradEl.getAttribute('spreadMethod') || 'pad';
    const stops = Array.from(gradEl.querySelectorAll('stop'));
    if (stops.length === 0) continue;

    const stopColors = stops.map(s => {
      const color = s.getAttribute('stop-color') ??
                    s.style.stopColor ?? 'black';
      const opacity = parseFloat(s.getAttribute('stop-opacity') ??
                                 s.style.stopOpacity ?? '1');
      return { color, opacity, brightness: parseColorBrightness(color) };
    });

    const allHighBrightness = stopColors.every(s =>
      s.brightness >= 230 && s.opacity >= 0.9
    );

    if (allHighBrightness) {
      const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
      const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
      const cycleWidth = Math.abs(x2 - x1);
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SPM-001',
        note: `fill="url(#${idMatch[1]})"; spreadMethod="${spreadMethod}"; ` +
              `gradient range: x1=${x1} x2=${x2} (cycle width=${(cycleWidth*100).toFixed(1)}% of element); ` +
              `all stop colors high-brightness (>=230 R,G,B): [${stopColors.map(s=>s.color).join(', ')}]; ` +
              `spreadMethod="${spreadMethod}" tiles/extends this all-white gradient → entire element fill white; ` +
              `consent text fill is solid white → invisible against white background; ` +
              `scanner checking for single dark stop MISSES; ` +
              `detection: check ALL stops; if all high-brightness flag regardless of spreadMethod; ` +
              `textContent="${text.slice(0,40)}..." intact; BCR non-zero`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SPM-001: A linearGradient with spreadMethod="repeat" defines its pattern in only 5% of the element width (x1="0" x2="0.05"), with both stops white. The repeat tiling extends this all-white pattern across the entire element, producing a solid-white fill. The gradient definition appears syntactically legitimate: it has two stops and a recognized spreadMethod. A scanner checking for a dark stop color finds none to flag. Detection requires checking all stop colors in gradients applied to consent text; if every stop is high-brightness (≥230 R,G,B), flag regardless of spreadMethod — the entire element fill will be near-white.

Attack 2: spreadMethod="reflect" on a 1%-cycle gradient produces sub-pixel near-white stipple (SA-CSS-SPM-002)

A linearGradient is defined with x1="0.0" x2="0.01" (a 1% element-width cycle) and stops: offset="0" color #111 (dark) and offset="1" color white. With spreadMethod="reflect", the gradient reflects: cycle 0–0.01 is dark→white, cycle 0.01–0.02 is white→dark (reflected), cycle 0.02–0.03 is dark→white again. The pattern repeats with a 2% element-width full mirror cycle. At a typical rendered element width of 200px, each cycle is 2px wide — an extremely narrow alternating band pattern.

At normal zoom levels, the human eye (and standard anti-aliasing) perceives very narrow alternating dark/light bands as a near-uniform gray or white average. The consent text rendered against this stippled gradient background has extremely low contrast — effectively invisible. A scanner checking stop colors finds #111 (a dark color) and concludes the gradient is not a whiteout — a false negative. Detection for short-cycle reflect gradients requires computing the rendered cycle width in pixels: cycle_fraction × element_BCR.width. If the cycle width is below 2px in rendered pixels, the stipple pattern is visually indistinguishable from solid fill and should be flagged as a potential hiding-via-stipple attack.

<!-- SA-CSS-SPM-002: spreadMethod="reflect"; x1=0.0 x2=0.01 (1% cycle); -->
<!-- stops: #111 (dark) at 0%, white at 100% -->
<!-- reflect: 0–1% dark→white, 1–2% white→dark, 2–3% dark→white … -->
<!-- cycle = 0.02 × element width; for 200px element: 4px per full cycle -->
<!-- at 1% cycle: 2px alternating bands → near-white stipple at normal zoom -->
<!-- scanner finds dark stop #111 → does NOT flag → false negative -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- cycle width = 0.01 element fraction; 0.01 × 200px = 2px per half-cycle -->
    <!-- reflect: alternating dark/white bands, 2px each → near-white stipple -->
    <!-- scanner sees dark stop (#111) → passes; short cycle width hides attack -->
    <linearGradient id="grad-spm-002"
        gradientUnits="objectBoundingBox"
        x1="0.0" y1="0" x2="0.01" y2="0"
        spreadMethod="reflect">
      <stop offset="0"   stop-color="#111111" stop-opacity="1"/>
      <stop offset="1"   stop-color="white"   stop-opacity="1"/>
    </linearGradient>
  </defs>
  <rect width="440" height="120" fill="white"/>

  <!-- near-white stipple fill; consent text effectively invisible at normal zoom -->
  <text x="20" y="50" font-size="13"
        fill="url(#grad-spm-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 rendered cycle width for short-cycle spreadMethod gradients; flag < 2px
function checkSpreadMethodReflectCycle(svgEl) {
  const findings = [];
  const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];

  for (const el of svgEl.querySelectorAll('[fill]')) {
    const text = el.textContent?.trim() ?? '';
    if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;

    const fillAttr = el.getAttribute('fill') ?? '';
    const idMatch = fillAttr.match(/url\(#([^)]+)\)/);
    if (!idMatch) continue;

    const gradEl = document.getElementById(idMatch[1]);
    if (!gradEl) continue;

    const spreadMethod = gradEl.getAttribute('spreadMethod') || 'pad';
    if (spreadMethod !== 'reflect' && spreadMethod !== 'repeat') continue;

    const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
    const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
    const cycleFraction = Math.abs(x2 - x1);

    // Compute rendered cycle width in pixels
    const bcr = el.getBoundingClientRect();
    const renderedCyclePx = cycleFraction * bcr.width;

    if (renderedCyclePx < 2 && renderedCyclePx > 0) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SPM-002',
        note: `fill="url(#${idMatch[1]})"; spreadMethod="${spreadMethod}"; ` +
              `gradient range: x1=${x1} x2=${x2}; cycle fraction=${(cycleFraction*100).toFixed(2)}%; ` +
              `element BCR.width=${bcr.width.toFixed(0)}px; ` +
              `rendered cycle width=${renderedCyclePx.toFixed(2)}px (<2px threshold); ` +
              `sub-pixel/narrow alternating bands → near-white stipple; ` +
              `gradient has dark stop → stop-color-only check MISSES; ` +
              `detection: compute cycle_fraction × BCR.width; flag renderedCyclePx < 2; ` +
              `textContent="${text.slice(0,40)}..." intact; BCR non-zero`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SPM-002: A linearGradient with spreadMethod="reflect" defines a 1%-cycle gradient (x1="0.0" x2="0.01") with a dark stop (#111) and a white stop. The reflected bands are 2px wide at a 200px element width — sub-pixel-scale alternating dark/light stripes that render as a near-white stipple at normal zoom. The consent text is effectively invisible. A scanner checking stop colors finds the dark #111 stop and does not flag the gradient. Detection requires computing the rendered cycle width in pixels (cycle_fraction × BCR.width) and flagging gradients where this value falls below 2px, regardless of stop color values.

Attack 3: spreadMethod="pad" with gradient placed outside the element — first-stop white pads entire fill (SA-CSS-SPM-003)

spreadMethod="pad" (the default) extends the first gradient stop color for the region before the gradient start, and the last gradient stop color for the region after the gradient end. An attacker sets the gradient range to x1="1.5" x2="2.0" in objectBoundingBox units, placing the entire gradient definition beyond the right edge of the element (the element occupies 0..1.0 in objectBoundingBox). Since the whole element (x=0..1.0) is before the gradient start (x=1.5), the pad rule extends the first stop color across the entire element. The first stop is white, so the entire element fill becomes solid white.

The gradient definition appears legitimate: it has stops, a defined x1/y1/x2/y2, and spreadMethod="pad" is the SVG default. The attack is entirely in the coordinate values: x1=1.5 and x2=2.0 are greater than 1.0, meaning the gradient is placed outside the element’s bounding box. Detection: for objectBoundingBox gradients, check if x1 > 1.0 (or y1 > 1.0 for vertical gradients, or x2 < 0 / y2 < 0 for off-left/top placement). When the gradient is entirely outside the element, the pad color fills the whole element; check the corresponding first or last stop color and flag if high-brightness.

<!-- SA-CSS-SPM-003: spreadMethod="pad" (default); gradient x1=1.5 x2=2.0 -->
<!-- gradient entirely beyond element right edge (x=0..1.0 in objectBoundingBox) -->
<!-- entire element is before gradient start (x<1.5) → padded with first stop -->
<!-- first stop = white → entire element fill is solid white -->
<!-- gradient has valid stops and coordinate values; x1/x2 > 1.0 is the attack -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- gradientUnits=objectBoundingBox (default); x1=1.5 x2=2.0: outside element -->
    <!-- spreadMethod=pad (default): x<1.5 padded with first stop (white) -->
    <!-- entire element (x=0..1.0) is in the padded region → solid white fill -->
    <linearGradient id="grad-spm-003"
        gradientUnits="objectBoundingBox"
        x1="1.5" y1="0" x2="2.0" y2="0"
        spreadMethod="pad">
      <stop offset="0"   stop-color="white"   stop-opacity="1"/>
      <stop offset="1"   stop-color="#334155" stop-opacity="1"/>
    </linearGradient>
  </defs>
  <rect width="440" height="120" fill="white"/>

  <!-- entire element padded white by out-of-bounds gradient; text invisible -->
  <text x="20" y="50" font-size="13"
        fill="url(#grad-spm-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 if objectBoundingBox gradient is entirely outside element; check pad stop color
function checkSpreadMethodPadOutside(svgEl) {
  const findings = [];
  const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];

  function parseColorBrightness(colorStr) {
    colorStr = (colorStr || '').trim().toLowerCase();
    if (colorStr === 'white' || colorStr === '#fff' || colorStr === '#ffffff') return 255;
    const hexMatch = colorStr.match(/^#([0-9a-f]{3,6})$/);
    if (hexMatch) {
      const h = hexMatch[1];
      if (h.length === 3) return Math.min(parseInt(h[0]+h[0],16), parseInt(h[1]+h[1],16), parseInt(h[2]+h[2],16));
      return Math.min(parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16));
    }
    return -1;
  }

  for (const el of svgEl.querySelectorAll('[fill]')) {
    const text = el.textContent?.trim() ?? '';
    if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;

    const fillAttr = el.getAttribute('fill') ?? '';
    const idMatch = fillAttr.match(/url\(#([^)]+)\)/);
    if (!idMatch) continue;
    const gradEl = document.getElementById(idMatch[1]);
    if (!gradEl) continue;

    const gradUnits = gradEl.getAttribute('gradientUnits') || 'objectBoundingBox';
    if (gradUnits !== 'objectBoundingBox') continue; // only handle BBB case here

    const spreadMethod = gradEl.getAttribute('spreadMethod') || 'pad';
    const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
    const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
    const y1 = parseFloat(gradEl.getAttribute('y1') ?? '0');
    const y2 = parseFloat(gradEl.getAttribute('y2') ?? '0');

    const stops = Array.from(gradEl.querySelectorAll('stop'));
    if (stops.length === 0) continue;
    const firstStop = stops[0];
    const lastStop  = stops[stops.length - 1];

    // Check if gradient is entirely beyond right edge (x1 > 1.0 and x2 > 1.0)
    // In this region entire element (0..1.0) is padded by first stop
    if (spreadMethod === 'pad' && x1 > 1.0 && x2 > 1.0) {
      const firstColor   = firstStop.getAttribute('stop-color') ?? '';
      const firstOpacity = parseFloat(firstStop.getAttribute('stop-opacity') ?? '1');
      const brightness   = parseColorBrightness(firstColor);
      if (brightness >= 230 && firstOpacity >= 0.9) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-SPM-003',
          note: `fill="url(#${idMatch[1]})"; gradientUnits="objectBoundingBox"; ` +
                `spreadMethod="pad" (default); x1=${x1} x2=${x2}: gradient beyond element right edge; ` +
                `entire element (x=0..1.0) is before gradient start (x=${x1}) → padded by first stop; ` +
                `first stop: stop-color="${firstColor}" opacity=${firstOpacity}; ` +
                `brightness=${brightness} (>=230 threshold): HIGH-BRIGHTNESS PAD COLOR; ` +
                `entire element fill is solid white; consent text invisible; ` +
                `gradient has dark last stop → single-stop check may miss; ` +
                `detection: for objectBoundingBox gradients, check x1 > 1.0 (or x2 < 0); ` +
                `when gradient outside element, pad color fills whole element; check first stop; ` +
                `textContent="${text.slice(0,40)}..." intact; BCR non-zero`
        });
      }
    }
    // Check if gradient is entirely before left edge (x1 < 0 and x2 < 0)
    // In this region entire element is padded by last stop
    if (spreadMethod === 'pad' && x1 < 0 && x2 < 0) {
      const lastColor   = lastStop.getAttribute('stop-color') ?? '';
      const lastOpacity = parseFloat(lastStop.getAttribute('stop-opacity') ?? '1');
      const brightness  = parseColorBrightness(lastColor);
      if (brightness >= 230 && lastOpacity >= 0.9) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-SPM-003',
          note: `fill="url(#${idMatch[1]})"; spreadMethod="pad"; x1=${x1} x2=${x2}: gradient before element left edge; ` +
                `entire element padded by last stop (stop-color="${lastColor}" opacity=${lastOpacity}); ` +
                `brightness=${brightness} >= 230: solid white fill; consent text invisible`
        });
      }
    }
  }
  return findings;
}

HIGH — SA-CSS-SPM-003: A linearGradient with spreadMethod="pad" (the SVG default) is placed entirely outside the element: x1="1.5" x2="2.0" in objectBoundingBox units, beyond the element’s right edge. The element (x=0..1.0) is entirely in the padded region before the gradient start; the pad rule extends the first stop color (white) across the entire element. The gradient has a dark last stop that might pass a stop-color check. Detection requires checking whether a gradient’s x1/y1 values exceed 1.0 (or x2/y2 fall below 0) for objectBoundingBox gradients; when the gradient is entirely outside the element, check the pad stop color and flag if high-brightness.

Attack 4: dynamic mousedown changes spreadMethod and stop coordinates to produce white fill, reverts at mouseup (SA-CSS-SPM-004)

At rest, the gradient has spreadMethod="pad" with a well-positioned range (x1="0" x2="1") and a dark-to-light color transition — the consent text is visible with a gradient fill. At mousedown, JavaScript simultaneously changes the gradient’s spreadMethod attribute to "repeat" and shifts x1/x2 to a short all-white range, or alternatively changes all gradient stop colors to white. The entire element fill becomes solid white and the consent text becomes invisible during the click window. At mouseup, the attributes are restored. Static inspection always observes the normal gradient.

A MutationObserver with subtree: true and attributeFilter: ['spreadMethod', 'stop-color', 'stop-opacity', 'offset', 'x1', 'x2', 'y1', 'y2'] on the gradient element (and its <stop> children) detects any of these mutations. After mutation, the full gradient analysis — stop colors, cycle width, and effective fill across the element extent — is re-run to confirm whether the post-mutation state produces a near-white fill.

<!-- SA-CSS-SPM-004: at rest spreadMethod="pad"; x1=0 x2=1; dark to light gradient -->
<!-- mousedown: spreadMethod="repeat" + x1=0 x2=0.05 + all stops white -->
<!-- entire element becomes solid white; text invisible during click -->
<!-- mouseup: restored; static scan sees normal gradient -->
<svg id="consent-svg-spm" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- at rest: normal gradient; consent text visible -->
    <linearGradient id="grad-spm-004"
        gradientUnits="objectBoundingBox"
        x1="0" y1="0" x2="1" y2="0"
        spreadMethod="pad">
      <stop id="stop-spm-004-0" offset="0"   stop-color="#111111" stop-opacity="1"/>
      <stop id="stop-spm-004-1" offset="1"   stop-color="#334155" stop-opacity="1"/>
    </linearGradient>
  </defs>
  <rect width="440" height="120" fill="white"/>

  <!-- at rest: dark gradient fill; text visible -->
  <!-- mousedown: spreadMethod=repeat + short all-white range → solid white → invisible -->
  <text x="20" y="50" font-size="13"
        fill="url(#grad-spm-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-spm');
  const grad  = document.getElementById('grad-spm-004');
  const stop0 = document.getElementById('stop-spm-004-0');
  const stop1 = document.getElementById('stop-spm-004-1');
  svg.addEventListener('mousedown', () => {
    // change spreadMethod to repeat; set short all-white gradient range
    grad.setAttribute('spreadMethod', 'repeat');
    grad.setAttribute('x1', '0');
    grad.setAttribute('x2', '0.05');
    stop0.setAttribute('stop-color', 'white');
    stop1.setAttribute('stop-color', 'white');
  }, true);
  svg.addEventListener('mouseup', () => {
    // restore normal gradient
    grad.setAttribute('spreadMethod', 'pad');
    grad.setAttribute('x1', '0');
    grad.setAttribute('x2', '1');
    stop0.setAttribute('stop-color', '#111111');
    stop1.setAttribute('stop-color', '#334155');
  }, true);
})();
</script>

// Detection: MutationObserver on gradient + stop children; post-mutation gradient analysis
function watchSpreadMethodMutations(svgEl) {
  const findings = [];

  const observer = new MutationObserver(mutations => {
    const affectedGrads = new Set();
    for (const mut of mutations) {
      // Walk up to find the linearGradient/radialGradient element
      let node = mut.target;
      while (node && node !== svgEl) {
        if (node.tagName === 'linearGradient' || node.tagName === 'radialGradient') {
          affectedGrads.add(node);
          break;
        }
        node = node.parentElement;
      }
    }

    for (const gradEl of affectedGrads) {
      const spreadMethod = gradEl.getAttribute('spreadMethod') || 'pad';
      const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
      const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
      const stops = Array.from(gradEl.querySelectorAll('stop'));
      const allWhite = stops.every(s => {
        const c = (s.getAttribute('stop-color') ?? '').trim().toLowerCase();
        const o = parseFloat(s.getAttribute('stop-opacity') ?? '1');
        return (c === 'white' || c === '#fff' || c === '#ffffff') && o >= 0.9;
      });
      const cyclePct  = Math.abs(x2 - x1) * 100;
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SPM-004',
        note: `Dynamic gradient mutation: spreadMethod="${spreadMethod}"; x1=${x1} x2=${x2} ` +
              `(cycle=${cyclePct.toFixed(1)}% element width); ` +
              `all stops white: ${allWhite}; ` +
              `post-mutation fill analysis: ${allWhite || cyclePct < 5 ? 'NEAR-WHITE FILL' : 'check required'}; ` +
              `at rest: spreadMethod=pad; dark gradient; static scan PASSES; ` +
              `MutationObserver subtree attributeFilter [spreadMethod,stop-color,stop-opacity,x1,x2,y1,y2] required; ` +
              `synthetic mousedown triggers mutation; post-mutation gradient re-analysis detects`
      });
    }
  });

  // Observe gradient elements and their stop children
  for (const gradEl of svgEl.querySelectorAll('linearGradient, radialGradient')) {
    observer.observe(gradEl, {
      attributes: true,
      subtree: true,
      attributeFilter: ['spreadMethod', 'stop-color', 'stop-opacity', 'offset', 'x1', 'x2', 'y1', 'y2'],
      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-SPM-004: At rest the gradient is a normal dark-to-dark fill and consent text is visible. At mousedown, JavaScript simultaneously changes spreadMethod to "repeat", shifts the gradient range to a 5%-width all-white pattern, and sets all stops to white — the entire element fill becomes solid white instantly. At mouseup, all attributes are restored. Static inspection always observes the safe gradient. Detection requires a MutationObserver with subtree: true on the gradient element, observing spreadMethod, stop-color, stop-opacity, offset, x1, x2, y1, y2 attribute changes. A post-mutation gradient analysis — combining stop color checks, cycle width, and pad geometry — determines whether the mutated gradient produces a near-white fill across the element.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-SPM-001: spreadMethod=repeat; 5%-width all-white gradient tiles solid white All stops white; short cycle (5% element width); repeat tiles solid white across full element; gradient has two stops and valid spreadMethod; textContent intact; BCR non-zero Single-stop dark-color check; requires: check ALL stop colors; if all stops high-brightness (≥230 R,G,B) flag regardless of spreadMethod Critical
SA-CSS-SPM-002: spreadMethod=reflect; 1%-cycle dark+white → sub-pixel stipple Cycle width 1% element fraction; 2px per half-cycle at 200px element; alternating dark/white bands → near-white stipple; dark stop (#111) present → stop-color check misses Stop-color-only check (dark stop present); requires: compute rendered cycle width = cycle_fraction × BCR.width; flag <2px as potential stipple whiteout Critical
SA-CSS-SPM-003: spreadMethod=pad; gradient at x1=1.5 x2=2.0 outside element Gradient beyond right edge (x1=1.5 > 1.0); entire element before gradient start → padded by first stop (white); gradient has dark last stop → may pass stop-color check Stop-color checks on defined range; requires: for objectBoundingBox gradients, check x1 > 1.0; when gradient outside element, check pad stop color; flag if high-brightness High
SA-CSS-SPM-004: mousedown swaps spreadMethod + stop colors to white; reverts mouseup At rest: dark gradient; mousedown: spreadMethod=repeat + short all-white range → solid white; text invisible during click; mouseup: restored; static scan sees normal gradient Static DOM; requires: MutationObserver subtree on gradient (spreadMethod, stop-color, x1/x2/y1/y2) + synthetic mousedown + post-mutation gradient analysis Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-SPM-001: SVG linearGradient spreadMethod=repeat on 5%-width all-white gradient tiles solid white fill across full consent text element — the linearGradient is defined with x1=0 x2=0.05 (5% of element width in objectBoundingBox coordinates) and both stops white (stop-color=white at offset=0 and offset=1); spreadMethod=repeat tiles this gradient pattern across the full element extent; every cycle is the same all-white pattern; the entire fill is solid white; the consent text (fill="url(#grad)") is invisible against the white SVG background; the gradient definition appears syntactically legitimate — it has two gradient stops, a defined coordinate range, and a recognized spreadMethod; a scanner checking for a dark stop color finds none — no flag; a scanner checking whether all stops are the same uniform color correctly identifies white-to-white but only within the defined 5% range; detection: check all stop colors in every gradient applied to consent text fill; if every stop is high-brightness (all R,G,B channels ≥ 230) flag the gradient regardless of spreadMethod value — the entire element fill will be near-white under repeat, reflect, or pad.
CRITICAL SA-CSS-SPM-002: SVG linearGradient spreadMethod=reflect with 1%-cycle gradient produces sub-pixel near-white stipple — stops #111 (dark) at 0% and white at 100%; reflect creates alternating bands: 0–1% dark→white, 1–2% white→dark, 2–3% dark→white; for a 200px wide element each half-cycle is 2px; at this density the alternating dark/light bands are imperceptible at normal zoom and standard anti-aliasing averages them to a near-uniform near-white tone; the consent text rendered in this near-white fill has effectively zero contrast against the white background and is invisible; a scanner checking stop colors finds the dark #111 stop and concludes the gradient is not a whiteout — false negative; detection: for gradients with spreadMethod=reflect or spreadMethod=repeat applied to consent text, compute the rendered half-cycle width in pixels as cycle_fraction × element_BCR.width; if the rendered cycle width is below 2px, the stipple bands are sub-visible and the gradient should be flagged as a potential stipple-whiteout attack regardless of individual stop colors.
HIGH SA-CSS-SPM-003: SVG linearGradient spreadMethod=pad with gradient at x1=1.5 x2=2.0 (outside element) — first-stop white pads entire element fill — spreadMethod=pad is the SVG default and extends the first gradient stop for the region before gradient start and the last stop for the region after gradient end; with gradientUnits=objectBoundingBox, setting x1=1.5 x2=2.0 places the entire gradient definition beyond the right edge of the element (which occupies x=0..1.0 in objectBoundingBox); the entire element is in the pre-gradient region (x < 1.5) and is filled with the first stop's pad color; the first stop color is white; the entire element fill is solid white; the gradient has a dark last stop (stop-color=#334155) that would pass a stop-color-only check; detection: for objectBoundingBox gradients, check whether x1 > 1.0 (or equivalently x2 < 0 for off-left placement, y1 > 1.0 / y2 < 0 for vertical); when the gradient is positioned entirely outside the element bounds in objectBoundingBox coordinates, the pad stop color fills the whole element; check the first or last stop accordingly and flag if high-brightness (≥230 R,G,B).
CRITICAL SA-CSS-SPM-004: dynamic spreadMethod swap at mousedown — gradient attributes mutated from dark to all-white at click time — at rest the linearGradient has spreadMethod=pad, x1=0 x2=1, dark stops (#111 and #334155) — a normal visible gradient fill on the consent text; at mousedown (capture phase) JavaScript simultaneously changes: spreadMethod from pad to repeat; x1/x2 from 0/1 to 0/0.05 (5% cycle); and both stop-color attributes from dark colors to white; the result is a short all-white repeat-tiled gradient that fills the entire element with solid white; the consent text is invisible during the click window; at mouseup all four attributes are restored to their original dark values; static DOM inspection always observes the dark gradient; detection requires: a MutationObserver with subtree=true on gradient elements and their stop children, with attributeFilter: ['spreadMethod','stop-color','stop-opacity','offset','x1','x2','y1','y2']; on any mutation, re-run the full gradient analysis (check stop colors, cycle width, x1/x2 position relative to element bounds) to determine whether the post-mutation state produces a near-white fill; dispatch a synthetic mousedown to trigger the attack pattern during scanning.

Related: SVG font-family consent attacks  |  SVG requiredFeatures consent attacks  |  SVG filterUnits consent attacks

← Blog  |  Security Checklist