MCP server CSS fill-opacity security: SVG consent text invisible fill via opacity zero, sub-threshold bypass, CSS vs attribute override, and stroke-only backup

Published 2026-09-26 — SkillAudit Research

SVG's rendering model separates fill color from fill alpha. The fill property defines the paint server (a color, gradient, or pattern). The fill-opacity property defines how opaque that paint is when composited. They are independent properties. An SVG element can have fill: #333 (a dark color) with fill-opacity: 0 (fully transparent alpha) — the fill color appears to exist when auditing the fill property, but nothing is rendered.

This is distinct from fill: transparent, which explicitly sets the fill paint server to a transparent color, and from fill: rgba(0,0,0,0), which encodes zero alpha into the color value itself. An auditor that checks the fill computed value for one of these patterns will find rgb(51, 51, 51) (dark gray) and report the fill as visible — without checking whether fill-opacity has been separately zeroed out.

Two ways to make SVG fill invisible: (1) Set fill: transparent or fill: rgba(0,0,0,0) — zero alpha is encoded in the color value. (2) Set fill: #333 + fill-opacity: 0 — non-zero color, but zero alpha from the separate property. Auditors checking only the fill color value catch method 1. They miss method 2 entirely.

Attack findings

HIGH
fill-opacity: 0 with non-transparent fill color — fill color audit passes; text invisible
An SVG text element has fill: #2d2d2d (a dark near-black color) and fill-opacity: 0. The fill color is a legitimate non-transparent value that would render consent text visibly if fill-opacity were 1. Setting fill-opacity to 0 makes the element invisible — but an auditor that checks getComputedStyle(el).fill will find rgb(45, 45, 45) (non-transparent) and report the fill as visible. Only a check of getComputedStyle(el).fillOpacity reveals that the opacity is zero. This distinction matters because most consent-text visibility auditors focus on fill color values (checking for 'transparent', 'none', or zero-alpha rgba) rather than the separate fill-opacity property.
<text style="
  fill: #2d2d2d;           /* non-transparent fill color */
  fill-opacity: 0;         /* ← zero fill alpha — NOT encoded in fill color */
  stroke: none;            /* no stroke backup */
">
  By clicking Accept, you agree to the above terms.
</text>

<!-- getComputedStyle(el).fill:        "rgb(45, 45, 45)"  → non-transparent → PASS (incorrect)
  getComputedStyle(el).fillOpacity: "0"              → zero → ← MISSED
  getBoundingClientRect(): positive rect             → text exists → PASS (incorrect)
  textContent: non-empty                             → content present → PASS (incorrect)
  Rendered pixels: 0 -->
HIGH
fill-opacity: 0.01 — passes opacity > 0 check; renders at 1% alpha; contrast below readable threshold
Setting fill-opacity: 0.01 produces fill rendering at 1% alpha. On a typical white (#fff) background, dark text at 1% fill-opacity has a contrast ratio of approximately 1.004:1 — effectively invisible. An auditor checking fillOpacity > 0 finds the value 0.01, which satisfies the condition. The text "exists" but is not readable. This is the fill-opacity analogue of opacity: 0.005 on an HTML element — a non-zero value designed to slip below detection thresholds while rendering nothing legible. Values in the range 0.01–0.04 are commonly used because they are above zero but produce rendering that fails all contrast accessibility standards.
<text style="
  fill: #000;
  fill-opacity: 0.01;     /* 1% alpha: non-zero → passes >0 check */
  stroke: none;
">
  By clicking Accept, you agree to the above terms.
</text>

<!-- fillOpacity: 0.01 → > 0 → PASS (incorrect)
  Effective color: rgba(0,0,0,0.01)
  On #fff background: contrast ratio ≈ 1.004 : 1
  WCAG 1.4.3 minimum (normal text): 4.5 : 1 — fails by a factor of 450x
  Visual result: imperceptibly faint gray on white — effectively invisible -->
MEDIUM
fill-opacity: 0 + stroke-opacity: 0.5 with thin stroke — stroke visible but illegible; text technically rendered
An SVG text element uses fill-opacity: 0 to make the fill invisible, but keeps stroke-opacity: 0.5 with a very thin stroke-width: 0.3. The stroke renders at 50% opacity but is hairline-thin — one third of a pixel wide on a 1x display. The character outlines are technically present as sub-pixel hairlines, but the rendering is illegible at any normal viewing distance. An auditor that checks fill-opacity finds zero and flags it, but if it then falls back to checking whether stroke provides backup visibility, it finds stroke-opacity: 0.5 (non-zero) and stroke-width: 0.3px (non-zero) and reports that the element is "visible via stroke." The stroke rendering is present but not legible.
<text style="
  fill: #333;
  fill-opacity: 0;          /* fill invisible */
  stroke: #333;
  stroke-opacity: 0.5;      /* 50% opacity — passes >0 check */
  stroke-width: 0.3;        /* 0.3px — sub-pixel hairline */
">
  By clicking Accept, you agree to the above terms.
</text>

<!-- fillOpacity: 0   → fill invisible (flagged by fill-opacity check)
  strokeOpacity: 0.5 → non-zero → fallback check: "visible via stroke"
  strokeWidth: 0.3px → non-zero → fallback check: "stroke has width"
  Combined: stroke renders at 50% opacity, 0.3px wide
  Visual result: sub-pixel hairline outlines — technically present, effectively illegible -->
MEDIUM
CSS fill-opacity overrides SVG fill-opacity attribute — attribute-checking auditors miss CSS-set values
SVG allows fill-opacity to be set both as a CSS property and as a presentation attribute: <text fill-opacity="1">. The CSS cascade gives author stylesheets higher priority than presentation attributes. An MCP server sets the SVG attribute fill-opacity="1" on the element (visible) and then overrides it via a CSS rule: .consent-text { fill-opacity: 0; }. An auditor that reads SVG attributes directly using el.getAttribute('fill-opacity') or el.getAttribute('style') finds the attribute value of 1 and reports the element as visible. Only an auditor using getComputedStyle(el).fillOpacity (which applies the full CSS cascade) sees the overriding CSS value of 0.
<!-- HTML: attribute shows fill-opacity = 1 -->
<text fill-opacity="1">
  By clicking Accept, you agree to the above terms.
</text>

<!-- Stylesheet: CSS overrides attribute -->
.consent-dialog text {
  fill-opacity: 0;          /* overrides attribute via CSS cascade */
}

<!-- el.getAttribute('fill-opacity'):   "1"  → visible → PASS (incorrect)
  getComputedStyle(el).fillOpacity: "0"  → invisible → ATTACK DETECTED
  CSS cascade: author stylesheet > presentation attribute
  Auditors using getAttribute miss CSS-set fill-opacity -->

Detection

function checkFillOpacity(svgRoot) {
  const textEls = svgRoot.querySelectorAll('text, tspan');
  const findings = [];

  for (const el of textEls) {
    if (!el.textContent.trim()) continue;

    const cs = getComputedStyle(el);
    /* Use getComputedStyle — not getAttribute — to catch CSS cascade overrides */
    const fillOpacity   = parseFloat(cs.fillOpacity   || '1');
    const strokeOpacity = parseFloat(cs.strokeOpacity || '1');
    const strokeWidth   = parseFloat(cs.strokeWidth   || '0');

    /* Check 1: fill-opacity exactly 0 */
    if (fillOpacity === 0) {
      /* Is there a visible stroke backup? */
      const strokeVisible = strokeWidth >= 0.5 && strokeOpacity >= 0.3;
      findings.push({
        severity: strokeVisible ? 'medium' : 'high',
        el,
        issue: `SVG text: fill-opacity:0 — fill channel invisible`
             + (strokeVisible
               ? `; stroke present at width:${strokeWidth}px opacity:${strokeOpacity} — check for legibility`
               : `; no readable stroke backup — text invisible`)
      });
    }

    /* Check 2: fill-opacity sub-threshold (0 < x ≤ 0.05) */
    if (fillOpacity > 0 && fillOpacity <= 0.05) {
      findings.push({
        severity: 'high', el,
        issue: `SVG text: fill-opacity:${fillOpacity} — passes >0 check but renders at ≤5% alpha; contrast below 1.1:1`
      });
    }

    /* Check 3: attribute vs CSS mismatch */
    const attrFillOpacity = el.getAttribute('fill-opacity');
    if (attrFillOpacity !== null) {
      const attrVal = parseFloat(attrFillOpacity);
      if (Math.abs(attrVal - fillOpacity) > 0.01) {
        findings.push({
          severity: 'medium', el,
          issue: `SVG text: fill-opacity attribute="${attrFillOpacity}" but CSS computed="${fillOpacity}" — CSS cascade override; attribute-based audits see wrong value`
        });
      }
    }
  }

  return findings.length ? findings : null;
}

Remediation

ControlHow it helps
Read fill-opacity via getComputedStyle(el).fillOpacity, not via el.getAttribute('fill-opacity')CSS cascade overrides SVG presentation attributes; computed style reflects the true rendering value after cascade application
Check fill-opacity in addition to fill color value when auditing SVG text elementsfill:#333 with fill-opacity:0 renders invisible; color-value checks find the dark color and incorrectly report visibility
Apply contrast-ratio measurement, not just an opacity > 0 thresholdfill-opacity:0.01 passes >0 checks but produces contrast ratios below 1.01:1; only contrast measurement catches sub-threshold values
When fill-opacity is 0, verify that the stroke channel renders legibly (stroke-width ≥ 0.5px, stroke-opacity ≥ 0.3, non-transparent stroke color)A sub-pixel or near-transparent stroke fallback is not a readable substitute for fill rendering; both channels must be checked

SkillAudit audits both fill-opacity and stroke-opacity on SVG consent elements using computed style values, not DOM attributes. Run a free audit on any MCP server GitHub URL to detect fill-opacity manipulation and the full SVG rendering-channel attack surface.