MCP server CSS stroke-opacity security: SVG consent text zero-opacity stroke, sub-threshold bypass, dual-channel near-invisible, and animated fade attack

Published 2026-09-26 — SkillAudit Research

CSS stroke-opacity controls the alpha channel of an SVG element's stroke independently of the stroke color and independently of the element's general opacity property. For HTML elements, there is only one opacity property. For SVG elements, the rendering model separates fill and stroke into two distinct compositing layers, each with its own opacity modifier: fill-opacity for the fill layer and stroke-opacity for the stroke layer.

This separation creates an audit blind spot. An MCP server that renders consent text via SVG with fill: transparent is using only the stroke channel to display text. Every character outline is drawn by the stroke paint server, not the fill. Setting stroke-opacity: 0 on that element eliminates the only visible rendering channel — but an auditor checking fill for the value transparent will find it and may stop there, never checking whether the stroke itself is visible. The text element exists in the DOM, has non-empty text content, passes bounding-rect checks, but renders zero pixels.

fill vs stroke audit gap: Auditing consent text visibility for SVG elements requires checking both rendering channels independently: fill color + fill-opacity AND stroke color + stroke-opacity. An element with fill: transparent and stroke: #333 is visible only through its stroke. Setting stroke-opacity: 0 then makes it invisible — but a fill-focused audit sees fill: transparent and may incorrectly flag the element as "using fill transparency" while missing that the stroke is also invisible.

Attack findings

HIGH
stroke-opacity: 0 with fill: transparent — complete SVG text invisibility; fill-focused audits miss it
An SVG text element is set with fill: transparent, stroke: #333, stroke-width: 1.5, and stroke-opacity: 0. The fill is transparent (zero fill alpha). The stroke color is set and non-zero-width, but stroke-opacity is zero. No fill pixels render. No stroke pixels render. The text element is invisible. An auditor that checks fill for transparent or rgba(0,0,0,0) finds it and flags it — but the flag is "uses transparent fill," which is a common pattern in SVG text elements that rely on stroke for rendering. The auditor may then check stroke color (#333) and stroke-width (1.5) and conclude that the stroke is rendering correctly, without ever checking stroke-opacity. The audit reports a warning about transparent fill but concludes the element is visible via stroke — incorrect.
<text style="
  fill: transparent;       /* transparent fill — passes 'is fill used?' check */
  stroke: #333;            /* non-null stroke color — passes 'is stroke set?' check */
  stroke-width: 1.5;       /* non-zero width — passes 'is width > 0?' check */
  stroke-opacity: 0;       /* zero alpha — makes stroke invisible — missed by fill check */
">
  By clicking Accept, you agree to the above terms.
</text>

<!-- getComputedStyle(el).fill:          "rgba(0, 0, 0, 0)"    → fill transparent: flagged
  getComputedStyle(el).stroke:        "rgb(51, 51, 51)"       → stroke set: PASS
  getComputedStyle(el).strokeWidth:   "1.5px"                 → width > 0: PASS
  getComputedStyle(el).strokeOpacity: "0"                     → ← MISSED by fill-only audits
  Rendered pixels: 0 -->
HIGH
stroke-opacity: 0.01 — passes opacity > 0 check; renders at 0.25% alpha below legible threshold
Setting stroke-opacity: 0.01 (1% opacity) on an SVG text stroke produces strokes with an alpha of approximately 2.55/255 — effectively invisible. At a typical stroke-width of 1px on a dark-on-light background, the resulting contrast ratio is far below the 3:1 minimum for text. An auditor that checks strokeOpacity > 0 will find 0.01 and report clean. The resulting rendering is indistinguishable from invisible to the human eye, but passes the >0 gate used by most automated checkers. This is the direct stroke-opacity equivalent of opacity: 0.001 on an HTML element.
<text style="
  fill: transparent;
  stroke: #000;
  stroke-width: 2;
  stroke-opacity: 0.01;   /* 1% alpha: non-zero → passes >0 check */
<!-- At #000 stroke on #fff background:
  Alpha = 0.01 → effective color ≈ rgba(0,0,0,0.01)
  Contrast ratio vs #fff ≈ 1.003 : 1 (WCAG minimum is 4.5:1 for normal text)
  Perceived luminance difference: imperceptible
  strokeOpacity: '0.01' → > 0 → PASS (incorrect) -->
">
  By clicking Accept, you agree to the above terms.
</text>
MEDIUM
stroke-opacity: 0.1 + fill-opacity: 0.1 — dual near-invisible channels; combined contrast fails threshold
An SVG text element uses both fill and stroke for rendering — a common pattern for thicker, bolder SVG text. The fill provides the body of each character and the stroke provides the outline. Setting both fill-opacity: 0.1 and stroke-opacity: 0.1 to the same low value means an auditor checking either channel independently finds a non-zero opacity and may report clean. The combined rendering produces text at roughly 10% of full opacity — technically visible but failing every contrast ratio requirement. On a light background, the text appears as a very faint gray shadow, legible only in ideal conditions.
<text style="
  fill: #111;
  fill-opacity: 0.1;       /* non-zero → passes fill-opacity > 0 check */
  stroke: #111;
  stroke-width: 0.5;
  stroke-opacity: 0.1;     /* non-zero → passes stroke-opacity > 0 check */
<!-- fill check:   fill-opacity 0.1 > 0     → PASS (incorrect)
  stroke check: stroke-opacity 0.1 > 0   → PASS (incorrect)
  Combined effective alpha ≈ 10%
  Contrast ratio vs #fff background ≈ 1.04 : 1 (far below 4.5:1 minimum)
  Rendered: near-invisible faint gray text -->
">
  By clicking Accept, you agree to the above terms.
</text>
MEDIUM
Animated stroke-opacity — fade from visible to 0 synchronized with Accept button activation
An SVG text element animates stroke-opacity from 1 to 0 over the same duration as the Accept button activation delay (typically 2–5 seconds). At load time, the text is fully visible (stroke-opacity: 1). As the reading timer progresses, the text fades. When the button activates, stroke-opacity reaches 0 and the text is invisible. A snapshot audit at load time sees stroke-opacity: 1 and reports clean. This is the stroke-opacity variant of the stroke-dashoffset consent timing attack. The fade is visible to observant users but registers as a "loading complete" or "dimming after reading" effect rather than an attack.
@keyframes stroke-fade-out {
  from { stroke-opacity: 1; }
  to   { stroke-opacity: 0; }
}

<text style="
  fill: transparent;
  stroke: #333;
  stroke-width: 1.5;
  animation: stroke-fade-out 5s linear forwards;
  /* Button activates at 5s — synchronized */
">
  By clicking Accept, you agree to the above terms.
</text>

<!-- t=0s: stroke-opacity: 1   → fully visible
  t=2.5s: stroke-opacity: 0.5 → 50% visible (reading window)
  t=5s:   stroke-opacity: 0   → invisible (button activates at t=5s)
  Snapshot audit at t=0: PASS (incorrect)
  Audit at t=5 (button activation): TIMING ATTACK -->

Detection

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

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

    const cs = getComputedStyle(el);
    const fill         = cs.fill || '';
    const fillOpacity  = parseFloat(cs.fillOpacity  || '1');
    const stroke       = cs.stroke || '';
    const strokeOpacity = parseFloat(cs.strokeOpacity || '1');
    const strokeWidth  = parseFloat(cs.strokeWidth   || '0');

    /* Determine if fill is the only channel */
    const fillInvisible = fill === 'none' || fill === 'transparent' ||
                          fill === 'rgba(0, 0, 0, 0)' || fillOpacity === 0;

    /* Check 1: fill invisible + stroke channel exists but stroke-opacity = 0 */
    if (fillInvisible && strokeWidth > 0 && strokeOpacity === 0) {
      findings.push({
        severity: 'high', el,
        issue: `SVG text: fill:transparent and stroke-opacity:0 — both rendering channels zero — text invisible`
      });
    }

    /* Check 2: stroke-opacity sub-threshold (0 < x ≤ 0.05) */
    if (strokeWidth > 0 && strokeOpacity > 0 && strokeOpacity <= 0.05) {
      findings.push({
        severity: 'high', el,
        issue: `SVG text: stroke-opacity:${strokeOpacity} — below legible threshold (≤0.05); passes >0 check but renders imperceptibly`
      });
    }

    /* Check 3: both fill-opacity and stroke-opacity both low */
    if (fillOpacity > 0 && fillOpacity <= 0.15 && strokeOpacity > 0 && strokeOpacity <= 0.15) {
      findings.push({
        severity: 'medium', el,
        issue: `SVG text: fill-opacity:${fillOpacity} and stroke-opacity:${strokeOpacity} — both channels below 15%; combined contrast likely fails 3:1 minimum`
      });
    }

    /* Check 4: animation on stroke-opacity */
    const animName = cs.animationName;
    if (animName && animName !== 'none' && fillInvisible) {
      findings.push({
        severity: 'medium', el, animName,
        issue: `SVG text: animated stroke-opacity (${animName}) on fill:transparent element — may be timing attack synchronized with button activation`
      });
    }
  }

  return findings.length ? findings : null;
}

Remediation

ControlHow it helps
Check stroke-opacity explicitly when fill is transparent or fill-opacity is 0fill-focused audits may miss that the stroke is the only rendering channel; stroke-opacity:0 eliminates it entirely
Apply a minimum threshold check: strokeOpacity > 0.05 is insufficient — use contrast ratio measurementValues like 0.01 or 0.02 pass simple >0 checks but produce contrast ratios below 1.1:1, far below accessible minimums
Flag animated stroke-opacity on SVG consent text elementsOpacity fade synchronized with button activation creates a timing attack — check final animated value at button activation time, not at load time
Audit both fill-opacity and stroke-opacity independently when both are set below 0.15Combined dual-low opacity fails contrast even when neither individual check triggers a zero-opacity alarm

SkillAudit checks all SVG opacity channels — fill, stroke, and element opacity — including animated values at button activation time. Run a free audit on any MCP server GitHub URL to detect stroke-opacity attacks and the full SVG consent manipulation surface.