Security Guide

MCP server SVG <stop> element consent security — JS mousedown stop-color override, stop-opacity="0" transparent gradient, all-stops-white flatten, and <linearGradient>-in-<pattern> nesting

The SVG <stop> element defines a color stop inside a <linearGradient> or <radialGradient>. Stop attributes — stop-color and stop-opacity — are SVG presentation attributes with their own inheritance and cascade rules, distinct from the element’s fill, opacity, and color properties. An overlay <rect> with fill="url(#grad)" references the gradient; audits that check fill as a color string see only a URL reference and cannot determine the effective cover color without traversing into the gradient definition and reading all stop values. Four attack patterns exploit this traversal gap against MCP consent UI.

Attack 1: JS-injected stop-color="#fff" override at mousedown (SA-CSS-STP-001)

An overlay <rect> has fill="url(#grad)" referencing a <linearGradient> whose stops produce a visible-colored gradient (for example, a gradient from dark grey to mid-grey that allows the consent text beneath to show through). At consent display time, the gradient is benign. A JavaScript mousedown listener iterates all <stop> children of the gradient definition and sets stop-color="#fff" (and stop-opacity="1") on each. The overlay immediately fills with solid white, covering the consent text at the moment of the click. A mouseup listener reverts all stop colors to their original values. The static DOM before the click shows a benign gradient; the static DOM after the click also shows the original benign gradient; the attack is present only during the 50–200ms click window.

<!-- SA-CSS-STP-001: mousedown injects stop-color="#fff" on all stops -->
<defs>
  <linearGradient id="bg-grad" x1="0" y1="0" x2="0" y2="1">
    <stop offset="0" stop-color="#e5e7eb"/>  <!-- benign: light grey -->
    <stop offset="1" stop-color="#d1d5db"/>  <!-- benign: slightly darker grey -->
  </linearGradient>
</defs>
<rect x="0" y="0" width="400" height="120" fill="url(#bg-grad)"/>

<script>
(function() {
  const grad = document.getElementById('bg-grad');
  const stops = grad.querySelectorAll('stop');
  const origColors = Array.from(stops).map(s => s.getAttribute('stop-color'));
  const origOpacities = Array.from(stops).map(s => s.getAttribute('stop-opacity'));

  document.addEventListener('mousedown', () => {
    stops.forEach(s => {
      s.setAttribute('stop-color', '#fff');
      s.setAttribute('stop-opacity', '1');
    });
  }, true);

  document.addEventListener('mouseup', () => {
    stops.forEach((s, i) => {
      s.setAttribute('stop-color', origColors[i]);
      if (origOpacities[i]) s.setAttribute('stop-opacity', origOpacities[i]);
      else s.removeAttribute('stop-opacity');
    });
  }, true);
})();
</script>

// Detection: MutationObserver watching stop-color changes during consent window
function monitorGradientStops(svgEl, onFinding) {
  for (const grad of svgEl.querySelectorAll('linearGradient, radialGradient')) {
    const obs = new MutationObserver(mutations => {
      for (const m of mutations) {
        if (m.attributeName === 'stop-color' || m.attributeName === 'stop-opacity') {
          const el = m.target;
          const newColor = el.getAttribute('stop-color');
          if (isBackgroundMatchingColor(newColor)) {
            onFinding({
              severity: 'CRITICAL', code: 'SA-CSS-STP-001',
              note: `stop-color dynamically set to "${newColor}" on <stop> element during click window — gradient fill changes to background-matching cover; static scan misses; MutationObserver required`
            });
          }
        }
      }
    });
    obs.observe(grad, { attributes: true, subtree: true,
      attributeFilter: ['stop-color', 'stop-opacity'], attributeOldValue: true });
  }
}

CRITICAL — SA-CSS-STP-001: JS-injected stop-color="#fff" on gradient stop elements at mousedown converts a benign gradient to a solid white cover during the click window. The attack leaves no static trace. A MutationObserver watching stop-color and stop-opacity attribute changes on all <stop> elements within gradient definitions used by the consent UI is the only reliable detection method.

Attack 2: stop-opacity="0" on all stops makes gradient fill fully transparent (SA-CSS-STP-002)

The stop-opacity attribute on a <stop> element controls the opacity of that color stop independently of the <linearGradient> element’s own opacity attribute and the referencing <rect>’s opacity. If all <stop> elements in a gradient have stop-opacity="0", the entire gradient fill is fully transparent — the overlay <rect> that references it becomes invisible. The consent text beneath is fully visible (no cover), which sounds harmless; the attack is that the consent panel background disappears, making the consent text illegible against whatever page content lies beneath the now-transparent panel. On a busy or high-contrast page background, this may render consent text unreadable. An opacity audit that checks getComputedStyle(overlayRect).opacity returns “1” (the element’s own opacity is unset); only per-stop stop-opacity produces the effect.

<!-- SA-CSS-STP-002: stop-opacity="0" on all stops makes gradient transparent -->
<defs>
  <linearGradient id="panel-bg" x1="0" y1="0" x2="0" y2="1">
    <!-- All stops have stop-opacity="0" -- gradient fill is fully transparent -->
    <stop offset="0"   stop-color="#fff" stop-opacity="0"/>
    <stop offset="0.5" stop-color="#f3f4f6" stop-opacity="0"/>
    <stop offset="1"   stop-color="#e5e7eb" stop-opacity="0"/>
  </linearGradient>
</defs>
<!-- Panel background: fill is transparent because all stops have stop-opacity=0 -->
<rect x="0" y="0" width="400" height="120" fill="url(#panel-bg)"/>
<!-- Consent text renders against page background (may be dark/busy) -->
<text x="20" y="60" fill="#111" font-size="14">Allow credential access</text>

// Detection: resolve effective gradient opacity; any stop with stop-opacity=0 is HIGH
function checkGradientStopOpacity(svgEl) {
  const findings = [];
  for (const grad of svgEl.querySelectorAll('linearGradient, radialGradient')) {
    const stops = grad.querySelectorAll('stop');
    if (!stops.length) continue;

    const allZeroOpacity = Array.from(stops).every(s => {
      const so = parseFloat(s.getAttribute('stop-opacity') ??
                            getComputedStyle(s).stopOpacity ?? '1');
      return so < 0.05;
    });

    if (allZeroOpacity) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-STP-002',
        note: `All <stop> elements in gradient #${grad.id} have stop-opacity≈0 — gradient fill is fully transparent; getComputedStyle(rect).opacity returns "1"; per-stop opacity audit required`
      });
    } else {
      for (const stop of stops) {
        const so = parseFloat(stop.getAttribute('stop-opacity') ??
                              getComputedStyle(stop).stopOpacity ?? '1');
        if (so < 0.05) {
          findings.push({
            severity: 'HIGH', code: 'SA-CSS-STP-002',
            note: `<stop offset="${stop.getAttribute('offset')}"> has stop-opacity="${stop.getAttribute('stop-opacity')}" — partial gradient transparency at this stop; combined effect may reduce contrast below WCAG threshold`
          });
        }
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-STP-002: stop-opacity="0" on all gradient stops makes the gradient fill fully transparent while getComputedStyle(el).opacity on the referencing <rect> returns “1”. Detection must traverse gradient definitions and read the stop-opacity attribute (not the element opacity) on each <stop> child. The stop-opacity attribute is also distinct from any CSS opacity property set on the <stop> element, so both getAttribute('stop-opacity') and getComputedStyle(stop).stopOpacity must be checked.

Attack 3: All stops at stop-color="white" flattens gradient to solid white cover (SA-CSS-STP-003)

A <linearGradient> with multiple <stop> elements at offsets from 0 to 1, all with stop-color="white" and stop-opacity="1", produces exactly the same visual output as fill="white" — a solid white rectangle. But a fill-color string check on the referencing <rect> reads fill="url(#grad)" and cannot determine the effective color without traversing the gradient and evaluating all stop colors. The gradient definition is technically valid (multiple stops at different offsets is normal). The attack evades any check that only inspects fill on the overlay element without resolving gradient references. Detection requires a gradient resolver: for any fill="url(#...)" that references a gradient, read all stops and compute whether the effective rendered color is background-matching (all stops white or near-white).

<!-- SA-CSS-STP-003: all stops white -- gradient equals fill="white" -->
<defs>
  <linearGradient id="cov" x1="0" y1="0" x2="1" y2="0">
    <stop offset="0"    stop-color="white" stop-opacity="1"/>
    <stop offset="0.25" stop-color="#ffffff" stop-opacity="1"/>
    <stop offset="0.5"  stop-color="rgb(255,255,255)" stop-opacity="1"/>
    <stop offset="0.75" stop-color="#fefefe" stop-opacity="1"/>
    <stop offset="1"    stop-color="white" stop-opacity="1"/>
  </linearGradient>
</defs>
<rect x="0" y="0" width="400" height="120" fill="url(#cov)"/>

// Detection: resolve all stop colors; if all are background-matching, flag CRITICAL
function resolveGradientEffectiveColor(gradEl) {
  const stops = gradEl.querySelectorAll('stop');
  if (!stops.length) return null;

  const stopColors = Array.from(stops).map(s => {
    const color = s.getAttribute('stop-color') || getComputedStyle(s).stopColor || 'black';
    const opacity = parseFloat(s.getAttribute('stop-opacity') ??
                               getComputedStyle(s).stopOpacity ?? '1');
    return { color: parseColor(color), opacity };
  });

  const allNearWhite = stopColors.every(({ color, opacity }) =>
    opacity > 0.9 && isNearWhite(color) // luminance > 0.9
  );
  const allNearBackground = stopColors.every(({ color, opacity }) =>
    opacity > 0.9 && isNearBackground(color) // matches page background ±15%
  );

  if (allNearWhite || allNearBackground) {
    return { severity: 'CRITICAL', code: 'SA-CSS-STP-003',
      note: `All stops in gradient #${gradEl.id} are near-white/background color — gradient equals fill="white"; fill-string check sees url() reference and passes; gradient stop resolution required` };
  }
  return null;
}

CRITICAL — SA-CSS-STP-003: A gradient where every <stop> has a near-white or background-matching stop-color produces a solid white cover visually indistinguishable from fill="white", but the fill attribute on the referencing element is a URL. Fill-color string checks that do not resolve gradient references miss this attack entirely. Detection must resolve all fill="url(#...)" references, traverse the gradient definition, and evaluate all stop colors together.

Attack 4: <stop> inside <linearGradient> inside <pattern> — nested gradient-in-pattern (SA-CSS-STP-004)

A <pattern> tile can contain a <linearGradient> and a <rect fill="url(#innerGrad)">. When the <pattern> is referenced by an overlay <rect fill="url(#pat)">, the effective fill is determined by the gradient stops inside the pattern tile. Modifying those <stop> elements changes the fill of every pattern tile across the entire overlay. An audit that resolves the outer fill URL and finds a <pattern> type must then also recurse into the pattern tile to find the inner <linearGradient> and read its stops. A single-level gradient traversal (resolve URL → check stops) fails; two-level traversal (resolve URL → identify pattern → resolve inner URL → check stops) is required. In practice, fill resolution should be recursive to handle arbitrary nesting depth.

<!-- SA-CSS-STP-004: gradient inside pattern -- stop changes affect all pattern tiles -->
<defs>
  <linearGradient id="inner-grad" x1="0" y1="0" x2="0" y2="1">
    <stop offset="0" stop-color="#f3f4f6"/>  <!-- light grey -->
    <stop offset="1" stop-color="#e5e7eb"/>  <!-- slightly darker grey -->
  </linearGradient>
  <pattern id="grad-tile" width="400" height="120" patternUnits="userSpaceOnUse">
    <!-- Tile fills with the inner gradient -->
    <rect width="400" height="120" fill="url(#inner-grad)"/>
  </pattern>
</defs>
<!-- Overlay fill references pattern, which references gradient -->
<rect x="0" y="0" width="400" height="120" fill="url(#grad-tile)"/>

// Detection: recursive fill URL resolver
function resolveEffectiveFill(el, svgEl, depth = 0) {
  if (depth > 5) return null; // prevent infinite recursion on malformed SVG
  const fill = el.getAttribute('fill') || getComputedStyle(el).fill;
  if (!fill || !fill.startsWith('url(')) return fill;

  const id = fill.match(/url\(#([^)]+)\)/)?.[1];
  if (!id) return fill;

  const ref = svgEl.getElementById(id);
  if (!ref) return fill;

  if (ref.tagName === 'pattern') {
    // Recurse into pattern tile children
    for (const child of ref.querySelectorAll('rect, circle, path, ellipse')) {
      const inner = resolveEffectiveFill(child, svgEl, depth + 1);
      if (inner) return inner;
    }
  }

  if (ref.tagName === 'linearGradient' || ref.tagName === 'radialGradient') {
    const stops = ref.querySelectorAll('stop');
    const stopSummary = Array.from(stops).map(s => ({
      color: s.getAttribute('stop-color'),
      opacity: s.getAttribute('stop-opacity') || '1'
    }));
    return { type: 'gradient', id, stops: stopSummary };
  }

  return fill;
}

CRITICAL — SA-CSS-STP-004: A <stop> inside a <linearGradient> inside a <pattern> is two levels of indirection from the overlay element’s fill attribute. Modifying those stops changes every pattern tile simultaneously. Single-level gradient traversal misses this pattern. Fill URL resolution must be recursive, following <pattern> references into their tile subtree before looking for gradients. Both the MutationObserver on stop attributes and the static resolution logic must operate at all nesting depths.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-STP-001: mousedown stop-color injection JS mousedown listener sets stop-color="#fff" on all gradient stops during click window; reverts at mouseup; static DOM clean before and after Static fill check (sees url() reference not #fff); pre-click DOM scan; requires MutationObserver on stop-color attribute during click window Critical
SA-CSS-STP-002: stop-opacity="0" transparent gradient All <stop> elements have stop-opacity="0"; gradient fill fully transparent; panel background disappears; text unreadable against page background getComputedStyle(rect).opacity returns "1" (element opacity unset); only per-stop stop-opacity audit detects; must check getAttribute + getComputedStyle on each stop Critical
SA-CSS-STP-003: all-stops-white gradient flatten Multiple stops at offsets 0..1 all with stop-color="white" — gradient equals fill="white" but fill attribute is url() reference; fill-string checks pass fill-string audit (sees url(), not "white"); requires gradient traversal, stop color collection, and all-near-white check across all stops Critical
SA-CSS-STP-004: gradient-in-pattern nesting <stop> inside <linearGradient> inside <pattern> — two indirection levels; single-level gradient traversal misses inner gradient; stop modification affects all pattern tiles Single-level fill URL resolution; requires recursive fill resolver following pattern→gradient→stop chain; MutationObserver must also cover nested gradient stops Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-STP-001: JS dynamically sets stop-color on gradient <stop> elements during mousedown/mouseup window — gradient fill converts to solid white cover during click; static DOM scan misses; MutationObserver on stop-color/stop-opacity attributes within gradient defs required.
CRITICAL SA-CSS-STP-002: All <stop> elements in gradient used by consent overlay have stop-opacity<0.05 — gradient fill is fully transparent; getComputedStyle(rect).opacity="1" does not detect; per-stop stop-opacity attribute traversal required.
CRITICAL SA-CSS-STP-003: All <stop> elements have stop-color near-white or background-matching — gradient is visually equivalent to fill="white" but fill attribute is url() reference; fill-string audit passes; gradient stop collection and all-near-white check required.
CRITICAL SA-CSS-STP-004: <stop> elements in <linearGradient> nested inside <pattern> — overlay fill references pattern which contains gradient; single-level URL traversal misses; recursive fill resolver following pattern→gradient→stop chain required.

Related: SVG pattern element consent attacks  |  Dynamic and temporal SVG consent hijack synthesis  |  SVG SMIL animate consent attacks

← Blog  |  Security Checklist