Security Guide

MCP server SVG paint server none consent security — fill="none" stroke="none" double-none ink suppression produces zero rendered pixels, textContent intact, parent BCR non-zero, and scanners checking opacity/visibility find nothing

SVG text elements use two independent paint mechanisms: fill (the interior ink of each glyph) and stroke (the outline drawn along each glyph contour). Setting either to none disables that paint server entirely. Setting both to none simultaneously causes the text element to produce zero rendered pixels on any conformant SVG renderer. The element remains in the DOM with its textContent fully intact, its getBoundingClientRect() returning a non-zero bounding box, and getComputedStyle returning fill: none; stroke: none — values that consent scanners checking for opacity: 0, visibility: hidden, display: none, or color: transparent will not flag. The attack is categorically distinct from fill-opacity: 0 (fill color present, opacity zeroed) and from opacity: 0 (element-level transparency). Four attack patterns: fill-only none, double-none, CSS paint-order interplay, and mousedown injection.

Attack 1: fill="none" stroke="none" — both paint servers disabled, zero glyph ink, consent text invisible (SA-CSS-PSN-001)

In the SVG paint model, every graphical element carries two independent paint servers: fill and stroke. The fill paints the closed interior of each glyph shape. The stroke paints along the outline path at a configurable width. The default values are fill="black" and stroke="none". When both are explicitly set to none, neither the glyph interiors nor the outlines produce any ink. The SVG renderer computes the text layout — glyph positions, advances, bounding boxes — and then skips the painting step entirely because both paint servers return “no paint.” The result is a fully laid-out but visually absent text element.

Scanners that test for CSS-style invisibility will find nothing suspicious. getComputedStyle(textEl).opacity returns 1. getComputedStyle(textEl).visibility returns visible. getComputedStyle(textEl).display returns inline. The element passes every standard visibility check. The BCR width and height are non-zero — the layout engine reserved space for the glyphs. The textContent property returns the consent string in full. Detection requires reading the SVG-specific fill and stroke attributes (or their computed equivalents via getComputedStyle with the fill property name, not the HTML color property) and checking that neither is none when both are present simultaneously.

<!-- SA-CSS-PSN-001: fill="none" stroke="none" on SVG text — both paint servers disabled -->
<!-- zero glyph ink: no fill interior, no stroke outline; text invisible -->
<!-- textContent = "Allow: shell execution, credential access" (intact) -->
<!-- getBoundingClientRect(): width > 0, height > 0 (layout computed, BCR non-zero) -->
<!-- getComputedStyle: opacity=1, visibility=visible, display=inline — no flags -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- fill="none" stroke="none": both paint servers → none; zero rendered pixels -->
  <!-- layout is computed; BCR non-zero; textContent intact; CSS-scanner finds nothing -->
  <text x="20" y="50" font-size="13" fill="none" stroke="none">
    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 SVG fill and stroke attribute values directly
function checkPaintServerNone(svgEl) {
  const findings = [];
  const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];

  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const text = textEl.textContent?.trim() ?? '';
    if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;

    // Read SVG-specific fill and stroke (not CSS color)
    const fill   = textEl.getAttribute('fill')   ?? textEl.style.fill   ?? '';
    const stroke = textEl.getAttribute('stroke') ?? textEl.style.stroke ?? '';
    const computedFill   = getComputedStyle(textEl).fill   ?? '';
    const computedStroke = getComputedStyle(textEl).stroke ?? '';

    const fillNone   = fill === 'none'   || computedFill.trim()   === 'none';
    const strokeNone = stroke === 'none' || computedStroke.trim() === 'none';

    // If both paint servers are none, zero ink is produced
    if (fillNone && strokeNone) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-PSN-001',
        note: `SVG <${textEl.tagName}> fill="${fill}" stroke="${stroke}"; ` +
              `both paint servers are "none"; zero rendered pixels; ` +
              `textContent="${text.slice(0,40)}..." intact; BCR non-zero; ` +
              `opacity=1, visibility=visible (CSS opacity scanner misses); ` +
              `distinct from fill-opacity:0 (which keeps fill color, uses opacity)`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-PSN-001: fill="none" stroke="none" on an SVG <text> element disables both paint servers simultaneously. The glyph interiors have no fill ink; the glyph outlines have no stroke ink. The text is invisible but its textContent is intact, BCR is non-zero, and all CSS-level visibility properties (opacity, visibility, display) are at their defaults. Detection requires checking the SVG-specific fill and stroke attributes — or their computed values via getComputedStyle(el).fill — and flagging consent-bearing elements where both resolve to none.

Attack 2: fill="none" alone on SVG text — glyph interiors invisible; stroke-width=0 default leaves zero visible ink (SA-CSS-PSN-002)

The default SVG stroke width is 0 in most browsers (or the stroke is none by default). Setting only fill="none" disables the glyph interior fill. If the element has no explicit stroke attribute (inheriting the default of none or stroke-width: 0), the text is effectively invisible. This is subtler than the double-none case: the element has no stroke="none" attribute in the DOM, so a scanner checking specifically for both attributes being set finds only one. The fix for detection is to resolve the effective paint state — if fill is none and the stroke either is none or has zero width, the element produces zero ink.

The distinction between fill="none" and fill-opacity="0" matters for scanner design. With fill-opacity="0", the fill color is still present (e.g., fill="black" fill-opacity="0"); an attacker could detect this by checking getComputedStyle(el).fillOpacity. With fill="none", there is no fill color at all — the paint server reference itself is none. Scanners that check getComputedStyle(el).opacity or parse the fill attribute as a CSS color (expecting a color value) and test its alpha channel will fail to flag fill="none" because none does not parse as a color.

<!-- SA-CSS-PSN-002: fill="none" only — glyph interiors invisible; default stroke=none also → zero ink -->
<!-- no explicit stroke attr; inherited default stroke is none (width=0 on most browsers) -->
<!-- fill-opacity scanner finds nothing (no fill-opacity attr; fill attr = "none" not a color) -->
<!-- color:transparent scanner finds nothing (no CSS color set; SVG uses fill not CSS color) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- fill="none": glyph interior has no paint; default stroke=none; zero visible ink -->
  <!-- no fill-opacity attr; no CSS opacity; no visibility:hidden; CSS scanners pass -->
  <text x="20" y="50" font-size="13" fill="none">
    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: effective paint state — fill "none" + stroke none/zero → zero ink
function checkEffectivePaintNone(svgEl) {
  const findings = [];
  const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];

  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const text = textEl.textContent?.trim() ?? '';
    if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;

    const cs = getComputedStyle(textEl);
    const fillVal   = textEl.getAttribute('fill')   || cs.fill   || '';
    const strokeVal = textEl.getAttribute('stroke') || cs.stroke || '';
    const strokeW   = parseFloat(cs.strokeWidth ?? cs['stroke-width'] ?? '0') || 0;

    const fillIsNone   = fillVal.trim()   === 'none';
    const strokeIsNone = strokeVal.trim() === 'none' || strokeW === 0;

    if (fillIsNone && strokeIsNone) {
      const bcr = textEl.getBoundingClientRect();
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-PSN-002',
        note: `SVG <${textEl.tagName}> fill="${fillVal}"; stroke="${strokeVal || 'unset'}"; ` +
              `stroke-width=${strokeW}; effective paint = zero ink; ` +
              `BCR w=${bcr.width.toFixed(0)}px h=${bcr.height.toFixed(0)}px (non-zero); ` +
              `textContent="${text.slice(0,40)}..." intact; ` +
              `fill-opacity and CSS opacity scanners both miss this (fill="none" is not a color); ` +
              `detection: resolve effective fill paint server + effective stroke paint/width`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-PSN-002: fill="none" without an explicit stroke renders zero ink when the inherited stroke is also none (the default). A scanner that checks fill-opacity or parses the fill value as a CSS color and tests alpha will not flag this: fill="none" is not a color, it is a paint server reference that means “no paint.” Detection must resolve the effective paint state: if fill resolves to none and stroke resolves to none or stroke-width is zero, the element is invisible regardless of what opacity/visibility checks report.

Attack 3: CSS fill: none via <style> inside SVG overrides attribute, leaves fill attribute a plausible color (SA-CSS-PSN-003)

SVG supports inline <style> elements with CSS rules that target SVG elements. A CSS rule text.consent-text { fill: none; stroke: none; } inside the SVG overrides the presentational fill attribute, which may be set to a plausible value like fill="#111" to mislead static attribute-checking scanners. The computed fill, however, is none. Scanners that read only the DOM attribute fill (returning #111) will incorrectly conclude the text is visible. Detection must use getComputedStyle to read the resolved fill value after CSS cascade, not the raw DOM attribute.

This pattern is analogous to the CSS-attribute override pattern used with color in HTML: an element may have a visible attribute value but an invisible computed value after cascading. In SVG the relevant computed properties are fill (resolves the paint server reference, returning none if the paint server is disabled) and stroke. The fill-opacity property is separate and irrelevant here — it controls alpha of an existing fill color; when fill is none there is nothing for fill-opacity to modulate.

<!-- SA-CSS-PSN-003: CSS fill:none overrides presentational fill="#111" attribute -->
<!-- attribute fill="#111" — attribute-only scanner reports visible fill -->
<!-- CSS rule .consent-text{fill:none;stroke:none} — computed fill=none (overrides attr) -->
<!-- getComputedStyle(el).fill = "none"; attribute scanner sees fill="#111" (PASS = false negative) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <style>
    /* CSS fill:none overrides SVG presentation attr fill="#111" */
    .consent-text { fill: none; stroke: none; }
  </style>
  <rect width="440" height="120" fill="white"/>

  <!-- fill="#111" looks plausible to attribute scanner; CSS rule overrides to none -->
  <!-- getComputedStyle(el).fill = "none" (CSS wins over presentational attr) -->
  <text x="20" y="50" font-size="13" fill="#111" class="consent-text">
    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: ALWAYS use getComputedStyle, never raw attribute, for fill/stroke checks
function checkComputedFillNone(svgEl) {
  const findings = [];
  const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];

  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const text = textEl.textContent?.trim() ?? '';
    if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;

    // Use getComputedStyle (post-cascade) not getAttribute (pre-cascade)
    const cs = getComputedStyle(textEl);
    const computedFill   = cs.fill   ?? textEl.getAttribute('fill')   ?? '';
    const computedStroke = cs.stroke ?? textEl.getAttribute('stroke') ?? '';
    const attrFill       = textEl.getAttribute('fill') ?? '';

    const effectiveFillNone   = computedFill.trim()   === 'none';
    const effectiveStrokeNone = computedStroke.trim() === 'none';

    if (effectiveFillNone && effectiveStrokeNone && attrFill !== 'none') {
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-PSN-003',
        note: `CSS overrides fill: attr="${attrFill}" → computedFill="${computedFill}"; ` +
              `computedStroke="${computedStroke}"; ` +
              `attribute fill="${attrFill}" appears valid (non-none); ` +
              `attribute-only scanner sees fill="${attrFill}" → PASS (false negative); ` +
              `getComputedStyle resolves cascade correctly; ` +
              `textContent="${text.slice(0,40)}..." intact; BCR non-zero; ` +
              `class="${textEl.getAttribute('class')}" used as CSS selector in <style>`
      });
    }
  }
  return findings;
}

HIGH — SA-CSS-PSN-003: A <style> element inside the SVG applies fill: none; stroke: none to a class. The text element carries a plausible presentational attribute fill="#111". CSS specificity rules mean the CSS class rule overrides the presentational attribute. A scanner reading only DOM attributes sees fill="#111" and passes. Detection requires getComputedStyle(el).fill — the post-cascade value that reflects CSS rules — not the raw attribute.

Attack 4: JS mousedown sets fill="none" on consent text, reverts at mouseup (SA-CSS-PSN-004)

The dynamic pattern sets fill="none" (and optionally stroke="none") on the consent text element precisely during a mouse press. At rest the element has a visible fill (e.g., fill="#111"). A mousedown listener in the capture phase calls textEl.setAttribute(‘fill’, ‘none’) immediately before the click registers. The renderer repaints the text as invisible during the click window. At mouseup, the fill is restored and the text reappears. Static inspection finds only the legitimate fill="#111" attribute.

Detection requires a MutationObserver watching the fill and stroke attributes on consent-bearing text elements. The observer must be configured with attributes: true and attributeFilter: [‘fill’, ‘stroke’]. After each mutation, recompute the effective paint state and flag transitions to the double-none condition. A synthetic mousedown event should be dispatched on the SVG container to trigger the injection before the observer checks.

<!-- SA-CSS-PSN-004: at rest fill="#111" (visible); mousedown: fill="none" stroke="none" -->
<!-- text invisible during click window; mouseup: fill restored -->
<!-- static scan sees fill="#111"; MutationObserver required to catch dynamic mutation -->
<svg id="consent-svg-psn" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- at rest: fill="#111" (visible); mousedown → fill="none" stroke="none" → invisible -->
  <text id="consent-text-psn" x="20" y="50" font-size="13" fill="#111">
    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-psn');
  const text = document.getElementById('consent-text-psn');
  svg.addEventListener('mousedown', () => {
    text.setAttribute('fill',   'none'); // glyph interior: no ink
    text.setAttribute('stroke', 'none'); // glyph outline: no ink
  }, true); // capture phase — before bubble-phase click handlers
  svg.addEventListener('mouseup', () => {
    text.setAttribute('fill',   '#111'); // restore
    text.removeAttribute('stroke');
  }, true);
})();
</script>

// Detection: MutationObserver watching fill/stroke attributes + synthetic mousedown
function watchFillNoneInject(svgEl) {
  const findings = [];
  const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];

  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      if (!['fill', 'stroke'].includes(mut.attributeName)) continue;
      const el = mut.target;
      const text = el.textContent?.trim() ?? '';
      if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;

      const fillVal   = el.getAttribute('fill')   ?? '';
      const strokeVal = el.getAttribute('stroke') ?? '';
      const fillNone   = fillVal.trim()   === 'none';
      const strokeNone = strokeVal.trim() === 'none' || strokeVal === '';

      if (fillNone) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-PSN-004',
          note: `Dynamic setAttribute('${mut.attributeName}', '${el.getAttribute(mut.attributeName)}') ` +
                `on consent text; fill="${fillVal}" stroke="${strokeVal}"; ` +
                `effective paint = zero ink during click window; ` +
                `at rest fill="${mut.oldValue}" (visible); static scan PASSES; ` +
                `MutationObserver attributeFilter=['fill','stroke'] subtree=true required; ` +
                `synthetic mousedown triggers injection; textContent="${text.slice(0,30)}..." intact`
        });
      }
    }
  });

  // Observe all consent text elements
  for (const el of svgEl.querySelectorAll('text, tspan')) {
    observer.observe(el, { attributes: true, attributeFilter: ['fill', 'stroke'], 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-PSN-004: JavaScript sets fill="none" and stroke="none" on the consent text at mousedown, making it invisible during the click window. At rest, fill="#111" is present and the text is visible; static scan passes. Detection requires a MutationObserver with attributeFilter: [‘fill’, ‘stroke’] on consent text elements, a synthetic mousedown dispatch to trigger the mutation, and an effective-paint-none check after each observed attribute change.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-PSN-001: fill="none" stroke="none" — double-none Both SVG paint servers disabled; zero glyph interior ink and zero glyph outline ink; textContent intact; BCR non-zero; CSS visibility properties at defaults opacity scanner; visibility scanner; display scanner; color:transparent scanner; fill-opacity scanner; requires: read fill attr/computed value and check for "none" Critical
SA-CSS-PSN-002: fill="none" alone — default stroke also none → zero ink fill="none" disables glyph interior; default stroke=none (SVG default); combined → zero ink; no explicit stroke attr in DOM (single-attr check misleads) fill-opacity scanner; CSS color scanner; stroke-explicit scanner; requires: resolve effective paint state (fill + stroke + stroke-width) Critical
SA-CSS-PSN-003: CSS fill:none overrides presentational fill="#111" attribute SVG <style> applies fill:none;stroke:none via class selector; DOM attr fill="#111" looks valid; CSS cascade overrides; computed fill=none Attribute-only scanner (sees fill="#111"); requires: getComputedStyle(el).fill post-cascade, not raw getAttribute("fill") High
SA-CSS-PSN-004: mousedown injects fill="none"; reverts mouseup At rest: fill="#111" (visible); mousedown (capture): fill="none" stroke="none"; click window = invisible; mouseup: fill restored; static scan passes Static DOM; requires: MutationObserver attributeFilter=['fill','stroke'] + synthetic mousedown + effective-paint-none check post-mutation Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-PSN-001: SVG <text> fill="none" stroke="none" — both SVG paint servers are set to "none"; the fill server controls the glyph interior ink and the stroke server controls the glyph outline ink; with both disabled, the text element produces zero rendered pixels on any conformant renderer; the element is fully laid out (glyphs positioned, advances computed), its textContent property returns the full consent string, and its getBoundingClientRect() returns a non-zero bounding box; getComputedStyle reports opacity:1, visibility:visible, display:inline — all CSS-level visibility checks pass; detection requires reading the SVG-specific fill attribute or its computed value via getComputedStyle(el).fill (not the CSS color or backgroundColor properties) and checking that it does not equal "none" simultaneously with stroke being none or stroke-width being zero.
CRITICAL SA-CSS-PSN-002: SVG <text> fill="none" — fill paint server disabled; SVG default stroke is also none (stroke-width defaults to 0 in most browsers); combined effective paint state = zero ink; no explicit stroke attribute in the DOM — a scanner checking for both fill and stroke attributes being set finds only one; a fill-opacity scanner finds nothing (fill="none" is a paint server reference, not a color with alpha); a CSS color scanner finds nothing (SVG uses fill not CSS color); effective paint state detection must resolve fill to none and resolve stroke paint/width to none/zero independently to flag this pattern.
HIGH SA-CSS-PSN-003: CSS cascade overrides fill="#111" attribute with fill:none — a <style> element inside the SVG contains a CSS class rule setting fill:none;stroke:none; the text element carries presentational attribute fill="#111" (a plausible visible color) and the class selector; CSS specificity places class selectors above presentational attributes in SVG; getComputedStyle resolves the fill to "none" post-cascade while getAttribute("fill") returns "#111"; an attribute-only scanner reports a visible fill and passes; detection must always use getComputedStyle(el).fill for the resolved fill value, not the DOM getAttribute result.
CRITICAL SA-CSS-PSN-004: JS mousedown setAttribute("fill", "none") on consent SVG text — at rest the text element has fill="#111" and is visually present; at mousedown (capture phase), both fill and stroke are set to "none"; the renderer repaints the text as invisible before any bubble-phase click handler fires; at mouseup the fill is restored to "#111"; static inspection finds fill="#111" at all times except during the click window; MutationObserver with attributeFilter ["fill", "stroke"] on consent text elements, combined with a synthetic mousedown dispatch and a post-mutation effective-paint-none check, is required to detect this dynamic pattern.

Related: SVG opacity consent attacks  |  SVG fill-rule consent attacks  |  SVG visibility consent attacks

← Blog  |  Security Checklist