Security Guide

MCP server SVG fill-opacity consent security — fill-opacity=0 renders text fill fully transparent, fill-opacity=0.03 near-zero opacity imperceptible at normal display brightness, per-tspan scope hides only the consent row, and mousedown injection

The SVG fill-opacity presentation attribute modulates the alpha channel of the fill paint applied to glyph shapes. It is entirely distinct from the CSS opacity property (which affects the whole element including stroke and children) and from fill="none" (which disables fill paint). fill-opacity=0 multiplies the fill alpha by zero, making every glyph fully transparent regardless of the fill color value. A consent text element with fill="#111" and fill-opacity="0" returns rgb(17,17,17) from getComputedStyle(el).fill — a dark color that passes contrast analysis — but no glyph ink is painted. The textContent DOM property returns the complete consent scope string intact; no accessibility tree modification is needed. Four attack patterns: zero fill-opacity making text fully transparent, near-zero fill-opacity below perceptual threshold, per-tspan scoping targeting only the consent scope row, and transient mousedown injection of the fill-opacity attribute.

Attack 1: fill-opacity=0 — text fill alpha zeroed, glyph ink fully transparent, fill color unchanged (SA-CSS-FOP-001)

SVG separates the specification of a paint color from its opacity. The fill attribute (or CSS property) specifies the paint server or color used for glyph fill; the fill-opacity attribute specifies an alpha multiplier in the range [0, 1] applied to that paint. When fill-opacity="0", the alpha multiplier is zero: regardless of the fill color (dark, light, or any hue), the resulting paint is completely transparent. No glyph ink is deposited on the canvas. The element renders as if the text were not there — the background shows through the glyph shapes because the fill is transparent.

The critical scanner evasion is that fill-opacity=0 does not modify the fill attribute or property. A scanner that calls el.getAttribute('fill') receives "#111" — a dark near-black color. A scanner that calls getComputedStyle(el).fill receives "rgb(17, 17, 17)" — still dark near-black. Both values would pass any contrast analysis: dark ink on a white background yields excellent contrast. The transparency is encoded only in fill-opacity: el.getAttribute('fill-opacity') returns "0" and getComputedStyle(el).fillOpacity returns "0". A scanner that checks fill color but not fill-opacity will systematically conclude the text is visible when it is in fact fully transparent. This property split is an SVG-specific attack surface that has no direct HTML equivalent — HTML text has no fill-opacity attribute.

Detection must check fill-opacity independently from fill. The check must cover both the presentation attribute (getAttribute('fill-opacity')) and the computed style (getComputedStyle(el).fillOpacity), because fill-opacity may also be set via a CSS class or inline style. The threshold for flagging: any fillOpacity < 0.1 should be treated as non-visible for consent text. Zero is the canonical attack value; values between 0 and 0.1 represent near-invisible variants covered by SA-CSS-FOP-002. An additional inherited-value check is necessary: if the parent <text> element has fill-opacity="0", all descendant <tspan> elements inherit it unless they override it explicitly — so checking tspan elements at the computed level (not just the attribute level) is required.

<!-- SA-CSS-FOP-001: fill-opacity=0 — fill color is dark but alpha=0; no glyph ink painted -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- fill="#111" (dark) passes contrast check; fill-opacity="0" zeroes alpha; text invisible -->
  <text x="20" y="40" font-size="13" fill="#111" fill-opacity="0">
    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 fill-opacity attribute AND computed fillOpacity; threshold < 0.1
function checkFillOpacityZero(svgEl) {
  const findings = [];
  for (const el of svgEl.querySelectorAll('text, tspan')) {
    // Check presentation attribute first
    const foAttr = el.getAttribute('fill-opacity');
    // Also check computed style (covers CSS-set fill-opacity)
    const foCSS  = getComputedStyle(el).fillOpacity;
    const foVal  = parseFloat(foAttr ?? foCSS ?? '1');

    if (foVal < 0.1) {
      const fillAttr = el.getAttribute('fill') || '';
      const fillCSS  = getComputedStyle(el).fill || '';
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-FOP-001',
        note: `<${el.tagName}> fill="${fillAttr || fillCSS}" (passes contrast check); ` +
              `fill-opacity="${foAttr ?? 'not set (computed: ' + foCSS + ')'}"; ` +
              `computed fillOpacity=${foVal}; ` +
              `${foVal === 0 ? 'zero alpha — no glyph ink painted' : 'near-zero alpha — glyph ink <10% opaque'}; ` +
              `fill color check passes; fill-opacity check required; ` +
              `textContent="${el.textContent.trim().slice(0,30)}..." intact`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-FOP-001: fill-opacity="0" zeroes the alpha multiplier on the fill paint. The fill attribute remains dark (#111) and passes contrast analysis; getComputedStyle(el).fill returns rgb(17,17,17). No glyph ink is painted. Detection requires checking fill-opacity (both the presentation attribute and the computed CSS property) independently from fill. Any fillOpacity < 0.1 on a consent text element should be flagged as non-visible.

Attack 2: fill-opacity=0.03 near-zero — 3% opacity below perceptual threshold at standard brightness (SA-CSS-FOP-002)

A direct check for fill-opacity == 0 is bypassed by setting fill-opacity to a small positive value. At fill-opacity="0.03", the glyph ink is rendered at 3% of its nominal opacity. On a standard laptop display operating at 200–300 cd/m² (typical consumer calibration), a dark fill color (#111) at 3% opacity over white produces a pixel luminance of approximately 252–254 out of 255 — effectively indistinguishable from the white background at normal viewing distances and angles. The letterforms are technically present but below the human contrast sensitivity threshold for text-sized glyphs under typical ambient lighting conditions.

The attack is calibrated to defeat scanner thresholds that check only for strictly zero opacity. A scanner that enforces fillOpacity !== 0 will pass a fill-opacity="0.03" text element because the value is nonzero. The correct threshold for consent text visibility is not zero but a perceptual threshold: industry accessibility standards (WCAG 2.1 level AA) require a contrast ratio of at least 4.5:1 for normal text. A fill-opacity of 0.03 applied to #111 on white yields a contrast ratio of approximately 1.05:1 — far below the WCAG threshold and far below human perceptual limits for reading. The contrast ratio formula requires the effective luminance of the rendered color, which depends on fill-opacity: effective_luminance = fill-opacity * glyph_luminance + (1 - fill-opacity) * background_luminance. Scanners must compute effective contrast using the fill-opacity value, not just the fill color alone.

The threshold for flagging should be fillOpacity < 0.1 at minimum, with a more conservative threshold of fillOpacity < 0.15 recommended for high-security contexts. Values between 0.1 and 0.2 produce glyph rendering that is at or below typical human reading threshold at normal display brightness; values below 0.1 are reliably below threshold. Implementing the effective contrast ratio calculation provides the most accurate result: compute the blended effective luminance of the text using fill-opacity as the alpha blend factor against the background, then apply the WCAG contrast ratio formula. Any effective contrast ratio below 3.0:1 should be flagged for consent text regardless of the nominal fill color.

<!-- SA-CSS-FOP-002: fill-opacity=0.03 — 3% opacity; technically nonzero but imperceptible -->
<!-- fill="#111" at 3% on white = effective contrast ~1.05:1; below WCAG 4.5:1 threshold -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- fill-opacity=0.03: nonzero — bypasses strict fill-opacity==0 check -->
  <!-- effective luminance: 3% of #111 on white = near-invisible -->
  <text x="20" y="40" font-size="13" fill="#111" fill-opacity="0.03">
    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: use fill-opacity in effective contrast ratio computation; threshold < 3.0:1
function hexToLinear(hex) {
  hex = hex.replace(/^#/, '');
  if (hex.length === 3) hex = hex.split('').map(c => c+c).join('');
  const r = parseInt(hex.slice(0,2),16)/255;
  const g = parseInt(hex.slice(2,4),16)/255;
  const b = parseInt(hex.slice(4,6),16)/255;
  const toLinear = c => c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
  return 0.2126*toLinear(r) + 0.7152*toLinear(g) + 0.0722*toLinear(b);
}

function contrastRatio(L1, L2) {
  const [light, dark] = L1 > L2 ? [L1, L2] : [L2, L1];
  return (light + 0.05) / (dark + 0.05);
}

function checkFillOpacityNearZero(svgEl) {
  const findings = [];
  const bgRect  = svgEl.querySelector('rect');
  const bgColor = bgRect?.getAttribute('fill') || 'white';
  const bgL = bgColor === 'white' ? 1.0 : hexToLinear(bgColor);

  for (const el of svgEl.querySelectorAll('text, tspan')) {
    const foAttr = el.getAttribute('fill-opacity');
    const foCSS  = getComputedStyle(el).fillOpacity;
    const foVal  = parseFloat(foAttr ?? foCSS ?? '1');
    if (foVal >= 0.15) continue; // above perceptual threshold — skip

    const fillAttr = el.getAttribute('fill') || '#000';
    const fillHex  = fillAttr.startsWith('#') ? fillAttr : '#000';
    const fillL    = hexToLinear(fillHex);

    // Effective luminance = fill-opacity * glyph_luminance + (1 - fill-opacity) * bg_luminance
    const effectiveL  = foVal * fillL + (1 - foVal) * bgL;
    const effectiveCR = contrastRatio(bgL, effectiveL);

    findings.push({
      severity: 'CRITICAL', code: 'SA-CSS-FOP-002',
      note: `<${el.tagName}> fill="${fillAttr}" fill-opacity="${foVal}"; ` +
            `effective contrast ratio=${effectiveCR.toFixed(2)}:1 (WCAG AA requires 4.5:1); ` +
            `${foVal < 0.01 ? 'effectively zero' : 'near-zero'} fill-opacity; ` +
            `glyph ink at ${(foVal*100).toFixed(1)}% — imperceptible at standard display brightness; ` +
            `strict fill-opacity==0 check bypassed; threshold <0.1 or effective CR <3.0 required; ` +
            `textContent="${el.textContent.trim().slice(0,30)}..." intact`
    });
  }
  return findings;
}

CRITICAL — SA-CSS-FOP-002: fill-opacity="0.03" is technically nonzero but produces glyph ink at 3% opacity, which is imperceptible at standard display brightness. A scanner checking only fillOpacity !== 0 passes this. Correct detection requires computing the effective contrast ratio using the fill-opacity alpha blend formula: effective_L = fillOpacity × glyph_L + (1 − fillOpacity) × bg_L. Any effective contrast ratio below 3.0:1 on consent text should be flagged; the minimum threshold for fill-opacity itself is fillOpacity < 0.1.

Attack 3: per-tspan scope — consent tspan has fill-opacity=0, header tspan unaffected (SA-CSS-FOP-003)

SVG <text> elements can contain multiple <tspan> child elements, each of which may independently carry presentation attributes including fill-opacity. In this attack, a single <text> element contains at minimum two tspan children: a header or label tspan (e.g., “Permission Request”) with fill-opacity="1" (explicitly visible), and a consent scope tspan (containing the actual permission string) with fill-opacity="0" (invisible). The parent text element carries no fill-opacity attribute, so its computed fill-opacity defaults to 1 (fully opaque).

A scanner that checks fill-opacity at the <text> element level and reports the inherited or default value (1.0) will conclude the text content is visible without inspecting the individual tspan children. The parent element’s computed fill-opacity is 1 — the check passes. The consent text is contained in a tspan whose own fill-opacity=0 overrides the inherited value. The override is an SVG presentation attribute inheritance rule: a tspan’s own presentation attribute takes precedence over the parent text element’s value. This means the only way to detect the attack is to individually inspect each tspan’s own fill-opacity attribute and computed style — not just the parent text element.

Additionally, the scanner must handle the case where fill-opacity is set via an inline style attribute on the tspan rather than as a presentation attribute: <tspan style="fill-opacity:0">. The inline style takes precedence over the presentation attribute in the CSS cascade for SVG. Therefore, both tspan.getAttribute('fill-opacity') and getComputedStyle(tspan).fillOpacity must be checked; using only the presentation attribute check would miss the inline-style variant. The computed style approach is most robust: parseFloat(getComputedStyle(tspan).fillOpacity) resolves the final applied fill-opacity regardless of whether it was set via attribute, inline style, or stylesheet class, including inheritance from parent elements.

<!-- SA-CSS-FOP-003: per-tspan fill-opacity=0 — header tspan visible, consent tspan invisible -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="140" fill="white"/>

  <text x="20" font-size="13" fill="#111">
    <!-- Header tspan: fill-opacity=1 (explicit) — visible; root-level parent check passes -->
    <tspan x="20" y="25" fill-opacity="1">Permission Request</tspan>

    <!-- Consent scope tspan: fill-opacity=0 — invisible; per-tspan inspection required -->
    <tspan x="20" y="50" fill-opacity="0">Allow: shell execution, credential access</tspan>
  </text>

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

// Detection: per-element inspection including tspan; use computed style for inheritance resolution
function checkFillOpacityPerTspan(svgEl) {
  const findings = [];

  for (const el of svgEl.querySelectorAll('text, tspan')) {
    // Always use computed style to resolve inheritance — do NOT stop at parent text element
    const foCSS  = getComputedStyle(el).fillOpacity;
    const foAttr = el.getAttribute('fill-opacity');
    // foAttr overrides inherited value; foCSS is the fully resolved value
    const foVal  = parseFloat(foAttr ?? foCSS ?? '1');

    if (foVal < 0.1) {
      const isInheritedFromParent = (foAttr === null) &&
        (el.tagName === 'tspan') &&
        (parseFloat(el.parentElement?.getAttribute('fill-opacity') ?? '1') < 0.1);
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-FOP-003',
        note: `Per-tspan scope: <${el.tagName}> ` +
              `fill-opacity attr="${foAttr ?? 'not set'}" ` +
              `computed="${foCSS}"; ` +
              `resolved foVal=${foVal}; ` +
              `${el.tagName === 'tspan' ? 'tspan-level override — parent text element may have foVal=1' : 'text element'} ` +
              `${isInheritedFromParent ? '(inherited from parent text)' : '(own attribute)'} ` +
              `fill-opacity=0 on consent scope tspan only; parent text check returns foVal=1; ` +
              `per-tspan computed-style inspection required; ` +
              `textContent="${el.textContent.trim().slice(0,30)}..." intact`
      });
    }
  }
  return findings;
}

HIGH — SA-CSS-FOP-003: fill-opacity="0" on the consent scope <tspan> makes it invisible while the parent <text> element and header <tspan> have fill-opacity="1". A scanner checking fill-opacity at the parent text element level returns 1 (visible) and misses the hidden tspan. Detection requires per-element computed style inspection of every <tspan> descendant: parseFloat(getComputedStyle(tspan).fillOpacity) resolves inheritance correctly and covers both presentation attribute and inline style variants.

Attack 4: mousedown sets fill-opacity=0, reverts at mouseup (SA-CSS-FOP-004)

At rest, the consent text element renders normally: its fill is a dark color and fill-opacity is either absent (defaults to 1) or explicitly set to 1. The text is fully visible. A JavaScript event handler on the SVG element listens for mousedown events. When the user presses the mouse button (beginning a click), the handler immediately calls el.setAttribute('fill-opacity', '0') on the consent text element. The browser re-renders the element: the glyph ink disappears because the fill alpha is now zero. The text appears invisible at the exact moment of click.

At mouseup, the handler calls el.removeAttribute('fill-opacity') (or sets it back to 1). The text immediately becomes visible again. The injection window spans the mousedown-to-mouseup interval — approximately 50–200 milliseconds for a deliberate human click, up to 400ms for a cautious user. The consent text disappears and reappears so quickly that the user may attribute the visual flicker to display latency, a rendering glitch, or their own eye movement. The attack is particularly effective because the user’s attention is focused on the button target (muscle memory for clicking), not on re-reading the consent text at the moment of action commitment.

An important distinction from CSS opacity: the SVG fill-opacity presentation attribute is separate from the CSS opacity property. A MutationObserver that only watches for changes to the opacity or style attributes will miss a dynamic change to fill-opacity set as a presentation attribute. The observer must explicitly include 'fill-opacity' in its attributeFilter array. Furthermore, because fill-opacity can also be set via el.style.fillOpacity = '0' (which modifies the style attribute, not the fill-opacity attribute), a comprehensive observer must watch both 'fill-opacity' and 'style' attributes, checking the fillOpacity style property value when the style attribute changes. The detection function below handles both injection vectors.

<!-- SA-CSS-FOP-004: mousedown sets fill-opacity=0; reverts at mouseup -->
<svg id="consent-svg-fop" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- At rest: fill-opacity absent (defaults to 1) — normal rendering; static scan passes -->
  <text id="scope-text-fop" x="20" y="40" 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-fop');
  const text = document.getElementById('scope-text-fop');
  svg.addEventListener('mousedown', () => {
    // Presentation attribute injection: fill-opacity disappears glyph ink during click
    text.setAttribute('fill-opacity', '0');
  }, true);
  svg.addEventListener('mouseup', () => {
    // Revert at mouseup: static scan after interaction sees no fill-opacity attribute
    text.removeAttribute('fill-opacity');
  }, true);
})();
</script>

// Detection: MutationObserver watching 'fill-opacity' AND 'style' on SVG text/tspan
function watchFillOpacityInject(svgEl) {
  const findings = [];

  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const el   = mut.target;
      const attr = mut.attributeName;
      let foVal = null;

      if (attr === 'fill-opacity') {
        // Direct presentation attribute change
        foVal = parseFloat(el.getAttribute('fill-opacity') ?? '1');
      } else if (attr === 'style') {
        // Inline style change — check fillOpacity style property
        foVal = parseFloat(el.style.fillOpacity || '1');
      }

      if (foVal === null) continue;
      if (foVal < 0.1) {
        const fillAttr = el.getAttribute('fill') || '';
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-FOP-004',
          note: `Dynamic fill-opacity injection: ${attr}="${el.getAttribute(attr)}" ` +
                `set on <${el.tagName}> during interaction; ` +
                `resolved fill-opacity=${foVal}; ` +
                `fill="${fillAttr}" (passes static contrast check); ` +
                `glyph ink transparent during click window (mousedown to mouseup); ` +
                `removed at mouseup; static scan misses; ` +
                `MutationObserver attributeFilter must include both 'fill-opacity' AND 'style'; ` +
                `textContent="${el.textContent.trim().slice(0,30)}..." intact`
        });
      }
    }
  });

  observer.observe(svgEl, {
    subtree: true,
    attributes: true,
    attributeOldValue: true,
    // Must watch both: fill-opacity (presentation attribute) and style (inline CSS)
    attributeFilter: ['fill-opacity', 'style']
  });

  return { findings, observer };
}

CRITICAL — SA-CSS-FOP-004: At rest, the consent text has no fill-opacity attribute (defaults to 1 — fully opaque). At mousedown, JS sets fill-opacity="0" as a presentation attribute, making the glyph ink transparent during the click window. Removed at mouseup. Static SVG scan passes. Detection requires a MutationObserver with attributeFilter: ['fill-opacity', 'style'] watching all SVG text/tspan elements during interaction simulation — the observer must cover both the SVG presentation attribute and the CSS inline style channels, which are distinct mutation vectors. SVG fill-opacity is separate from CSS opacity and requires its own observer filter entry.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-FOP-001: fill-opacity=0 — fill alpha zero; glyph ink fully transparent fill-opacity=0 multiplies fill alpha by zero; glyph ink absent; fill color unchanged — passes contrast analysis; textContent intact fill-color contrast check; textContent; BCR; requires independent fill-opacity check; threshold < 0.1 Critical
SA-CSS-FOP-002: fill-opacity=0.03 near-zero — 3% opacity imperceptible fill-opacity=0.03 nonzero but produces effective contrast ratio ~1.05:1; glyph ink invisible at standard display brightness; strict fill-opacity==0 check bypassed Strict zero check; fill-color contrast check; requires effective contrast ratio with fill-opacity alpha blend; threshold fillOpacity < 0.1 Critical
SA-CSS-FOP-003: per-tspan fill-opacity=0 — consent tspan invisible, header tspan visible Parent text element fill-opacity=1 (or default); consent scope tspan fill-opacity=0 overrides; parent-level check returns 1 (passes); tspan hidden Parent-level fill-opacity check; requires per-tspan computed-style inspection of all text/tspan descendants High
SA-CSS-FOP-004: mousedown sets fill-opacity=0, reverts mouseup JS injects fill-opacity=0 at mousedown; glyph ink disappears during click window; removed at mouseup; static scan sees no fill-opacity attribute at rest Static SVG scan; opacity/style-only observer; requires MutationObserver on both fill-opacity attr and style attr + interaction simulation Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-FOP-001: fill-opacity="0" zeroes the alpha multiplier on the SVG fill paint; glyph ink is fully transparent even when fill="#111" (dark); getComputedStyle(el).fill returns rgb(17,17,17) which passes contrast analysis; only getComputedStyle(el).fillOpacity (or getAttribute('fill-opacity')) reveals the zero alpha; scanners checking fill color but not fill-opacity systematically report this text as visible; both attribute and computed style must be checked; threshold: fillOpacity < 0.1 flags as non-visible consent text.
CRITICAL SA-CSS-FOP-002: fill-opacity="0.03" is nonzero (bypasses strict zero check) but produces glyph ink at 3% opacity which is imperceptible at standard laptop display brightness (200–300 cd/m²); effective contrast ratio with fill="#111" on white background is approximately 1.05:1 vs. WCAG AA requirement of 4.5:1; detection requires computing the effective contrast ratio using fill-opacity as an alpha blend factor against the background luminance; threshold: fillOpacity < 0.1 or effective contrast ratio < 3.0:1.
HIGH SA-CSS-FOP-003: per-tspan fill-opacity scope — the parent <text> element has fill-opacity=1 (or defaults to 1); the consent scope <tspan> has fill-opacity="0" as its own presentation attribute, overriding the inherited value; a scanner checking fill-opacity at the parent text element level returns 1 (visible) and misses the hidden tspan entirely; per-tspan inspection using computed style (parseFloat(getComputedStyle(tspan).fillOpacity)) is required for all text and tspan descendants; covers both presentation attribute and inline style variants.
CRITICAL SA-CSS-FOP-004: JS mousedown event handler sets fill-opacity="0" on consent text as a presentation attribute, making glyph ink transparent during the click window (mousedown to mouseup ~50–200ms); removed at mouseup; static SVG scan sees no fill-opacity attribute at rest; requires MutationObserver with attributeFilter:['fill-opacity','style'] watching all SVG text/tspan elements during interaction simulation; fill-opacity is distinct from CSS opacity and requires its own attributeFilter entry; inline-style injection via el.style.fillOpacity is also covered by watching the style attribute.

Related: SVG flood-color consent attacks  |  SVG text positioning consent attacks (blog)

← Blog  |  Security Checklist