Security Guide
MCP server SVG stroke-opacity consent security — fill=none + stroke + stroke-opacity=0 eliminates the sole rendering channel making text invisible, near-zero stroke-opacity=0.02 below perceptual threshold, per-tspan scope stroke-opacity=0 while parent stroke is visible, and mousedown injection
SVG stroke-opacity is a presentation attribute that controls the opacity of the stroke paint channel independently from fill-opacity and from the element-level opacity. In ordinary text rendering, fill paints the glyph interiors and stroke optionally outlines the letterforms; if fill is present and visible, setting stroke-opacity=0 makes no perceptible difference. The attack surface arises specifically when fill="none": with no fill channel contributing any paint, the stroke becomes the sole rendering channel for the glyph outlines. At stroke-opacity=0 the stroke is fully transparent, and with no fill paint either, absolutely no ink is applied to the glyph shapes — the text element is completely invisible. textContent returns the full consent string; getBoundingClientRect returns valid dimensions; getComputedStyle(el).stroke returns a visible color. Detection requires checking both the fill value and the stroke-opacity in combination. Four attack patterns: fill=none + stroke-opacity=0 (sole channel eliminated), near-zero stroke-opacity=0.02, per-tspan scope stroke-opacity=0, and transient mousedown injection.
Attack 1: fill=none + stroke set + stroke-opacity=0 — stroke is sole rendering channel, opacity=0 eliminates all glyph ink (SA-CSS-SOP-001)
SVG text glyph rendering involves two independent paint operations: the fill (paints the interior of each glyph shape) and the stroke (paints a path along the outline of each glyph at the specified stroke-width). By default both can contribute ink, but when fill="none" (or equivalently fill="transparent"), the fill paint operation is suppressed entirely. In this configuration, the stroke is the only source of glyph ink. The letterforms are visible solely because the stroke path traces around each character outline, producing a thin outlined rendering of the text. This is a legitimate SVG text styling technique — outline text (sometimes called “hollow text”) is commonly used in display typography and SVG animations.
The attack sets fill="none" and assigns a visible stroke color (e.g., stroke="#111"), then sets stroke-opacity="0". With stroke-opacity at zero, the stroke paint is fully transparent. No fill paint is present (fill=none), and no stroke paint is present (stroke-opacity=0). The result is that no ink reaches the canvas for any glyph shape. The SVG element occupies space, its bounding box is non-zero, its textContent contains the full consent string, and its stroke attribute reports a visible dark color — but visually the text element is completely blank. The consent text is rendered invisible by eliminating its sole ink channel.
What makes this attack particularly dangerous for automated detection is the mismatch between what property-reading produces and what the user actually sees. A scanner that reads getComputedStyle(el).fill to check whether it is none and then checks the stroke color for visibility will find: fill is none (expected), stroke is a dark color (#111, opaque-looking). But it misses that strokeOpacity is 0. A scanner that checks getComputedStyle(el).stroke and finds a dark color, and does not separately check strokeOpacity, will report the text as visible. The correct detection gate is: when fill is none or transparent, check stroke-opacity; if stroke-opacity < 0.1, flag as invisible regardless of the stroke color value.
<!-- SA-CSS-SOP-001: fill=none + stroke=#111 + stroke-opacity=0 -->
<!-- Stroke is sole rendering channel; stroke-opacity=0 eliminates all glyph ink -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- fill=none: no fill ink. stroke-opacity=0: no stroke ink. Text invisible. -->
<!-- getComputedStyle(el).stroke returns "rgb(17,17,17)" — looks visible -->
<!-- getComputedStyle(el).strokeOpacity returns "0" — caught only with separate check -->
<text x="20" y="40" font-size="13"
fill="none"
stroke="#111"
stroke-width="0.5"
stroke-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: when fill=none, stroke-opacity must be checked separately
function checkStrokeOpacityStrokeOnly(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('text, tspan')) {
const cs = getComputedStyle(el);
const fillAttr = el.getAttribute('fill') || '';
const fillCSS = cs.fill || '';
const isFillNone = /^none$/i.test(fillAttr) ||
/^none$/i.test(fillCSS) ||
/rgba?\(0,\s*0,\s*0,\s*0\)/i.test(fillCSS);
if (!isFillNone) continue; // only dangerous when stroke is sole channel
const strokeOpacityAttr = el.getAttribute('stroke-opacity') || '';
const strokeOpacityCSS = cs.strokeOpacity || '1';
const strokeOpacity = parseFloat(strokeOpacityAttr !== '' ? strokeOpacityAttr : strokeOpacityCSS);
const strokeColor = el.getAttribute('stroke') || cs.stroke || '';
const hasStroke = strokeColor && !/^none$/i.test(strokeColor);
if (hasStroke && strokeOpacity < 0.1) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-SOP-001',
note: `<${el.tagName}> fill="${fillAttr || fillCSS}" (none — stroke is sole channel); ` +
`stroke="${strokeColor}"; stroke-opacity="${strokeOpacity}"; ` +
`stroke is sole rendering channel but stroke-opacity < 0.1; ` +
`no fill ink + no stroke ink = zero glyph paint; ` +
`getComputedStyle(el).stroke returns visible color but strokeOpacity=0; ` +
`textContent="${el.textContent.trim().slice(0,30)}..." intact`
});
}
}
return findings;
}
CRITICAL — SA-CSS-SOP-001: fill="none" makes the stroke the sole rendering channel. stroke-opacity="0" then eliminates that channel entirely, leaving no glyph ink. getComputedStyle(el).stroke returns a visible color — the deception. Detection requires checking both the fill value (is it none/transparent?) and strokeOpacity as a combined condition: when fill is none and stroke-opacity < 0.1, the text element is invisible.
Attack 2: near-zero stroke-opacity=0.02 — 2% opacity on stroke-only text, below perceptual threshold (SA-CSS-SOP-002)
Rather than setting stroke-opacity to the absolute zero value that might be caught by a simple equality check, this attack uses a fractional near-zero value: stroke-opacity="0.02". Two percent opacity means the stroke paint is composited at 2% of its nominal color value onto the background. For a dark stroke color such as stroke="#111" (RGB 17,17,17) composited at 2% over a white background (RGB 255,255,255), the resulting pixel color is approximately RGB 252,252,252 — essentially white. At typical display brightness and screen densities, the 2% ink level is far below the contrast threshold needed for human perception. The glyph outlines are rendered as a near-white hairline against the white background; the text is functionally invisible to any viewer.
This attack is specifically designed to bypass naive threshold checks. A scanner that flags stroke-opacity === 0 will miss stroke-opacity = 0.02. A scanner that flags only stroke-opacity < 0.01 will also miss it. The WCAG contrast formula produces an effective contrast ratio near 1.0 for this combination, which is the same as invisible text, but a scanner checking only whether stroke-opacity > 0 as a boolean will treat 0.02 as a “visible” value. The correct threshold is stroke-opacity < 0.1 when the fill is none, which catches both the zero and near-zero variants. With a stroke-opacity below 0.1, even a saturated opaque stroke color would produce less than 10% of its nominal ink level, which at typical brightness levels does not meet any reasonable perceptual visibility criterion.
The interaction with stroke-width further affects visibility: a near-zero opacity stroke with a thick stroke-width may be marginally more visible than one with a hairline width, but at 2% opacity the difference remains sub-threshold for typical display calibration. A thorough scanner should compute an effective contrast metric: effectiveContrast = contrastRatio(stroke_color_at_background) * strokeOpacity — when this product falls below 1.5, the stroke-only text is not reliably perceptible. Additionally, the near-zero value could be expressed as a CSS percentage (stroke-opacity: 2% in an inline style), a decimal close to zero, or a very small floating-point value; all of these representations must be normalized to a float in [0,1] before threshold comparison.
<!-- SA-CSS-SOP-002: fill=none + stroke=#111 + stroke-opacity=0.02 -->
<!-- 2% opacity: stroke-only text rendered near-white on white background -->
<!-- A scanner checking strokeOpacity > 0 passes this; threshold check required -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- stroke-opacity=0.02: 2% of stroke color composited; near-white on white bg -->
<!-- effective pixel color ≈ RGB(252,252,252) on white — invisible -->
<text x="20" y="40" font-size="13"
fill="none"
stroke="#111"
stroke-width="0.6"
stroke-opacity="0.02">
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: threshold check on stroke-opacity when fill=none; boolean >0 check insufficient
function checkNearZeroStrokeOpacity(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('text, tspan')) {
const cs = getComputedStyle(el);
const fillAttr = el.getAttribute('fill') || '';
const fillCSS = cs.fill || '';
const isFillNone = /^none$/i.test(fillAttr) ||
/^none$/i.test(fillCSS) ||
/rgba?\(0,\s*0,\s*0,\s*0\)/i.test(fillCSS);
if (!isFillNone) continue;
const strokeColor = el.getAttribute('stroke') || cs.stroke || '';
const hasStroke = strokeColor && !/^none$/i.test(strokeColor);
if (!hasStroke) continue;
const soAttr = el.getAttribute('stroke-opacity');
const soCSS = cs.strokeOpacity;
const strokeOpacity = parseFloat(soAttr != null ? soAttr : (soCSS || '1'));
// boolean >0 check would pass 0.02; threshold <0.1 required
if (strokeOpacity > 0 && strokeOpacity < 0.1) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-SOP-002',
note: `<${el.tagName}> fill=none (stroke-only); stroke="${strokeColor}"; ` +
`stroke-opacity="${strokeOpacity}" — near-zero (${(strokeOpacity*100).toFixed(1)}%); ` +
`effective pixel color near-white on white background; below perceptual threshold; ` +
`a boolean strokeOpacity>0 check passes this; threshold strokeOpacity<0.1 required; ` +
`textContent="${el.textContent.trim().slice(0,30)}..." intact`
});
}
}
return findings;
}
CRITICAL — SA-CSS-SOP-002: stroke-opacity="0.02" (2%) on stroke-only text (fill=none) renders glyph outlines at 2% of the stroke color — near-white on a white background, below perceptual visibility threshold. A scanner checking strokeOpacity > 0 passes this attack. The correct detection threshold is strokeOpacity < 0.1 when fill is none or transparent, applied after normalizing the value to a float in [0,1] regardless of its representation (decimal, CSS percentage, or computed style string).
Attack 3: per-tspan scope stroke-opacity=0 — parent has visible stroke, consent scope tspan invisible (SA-CSS-SOP-003)
The SVG tspan element allows independent styling of sub-runs of text within a parent text element. Presentation attributes on a tspan override the inherited values from the parent for that specific span. This inheritance override mechanism is the basis for the per-tspan scope attack: the parent text element is styled with fill="none", stroke="#111", and stroke-opacity="1" — a fully visible stroke-only text rendering. A header tspan inherits these properties and renders visibly. The consent scope tspan — the one containing the permission description — adds a stroke-opacity="0" attribute override, suppressing the stroke to full transparency for that run of text only. The header is visible; the scope text is invisible.
This attack is designed to defeat scanners that operate at the text element level rather than descending into child tspan elements. A scanner that reads getComputedStyle(textEl).strokeOpacity on the parent text element will get 1 — the parent’s value — because the tspan override does not propagate upward through the DOM. The scanner concludes the text is visible. Only a scanner that iterates over each tspan individually and checks getComputedStyle(tspanEl).strokeOpacity (or reads the stroke-opacity attribute on each tspan) will detect that the scope run is silenced. The per-tspan visibility check must also account for the fill value of each tspan (potentially overriding the parent’s fill=none back to a visible fill) and the inherited fill from the parent.
A further subtlety: the visible header tspan creates the visual impression that the consent panel is functioning correctly. The user sees header text (e.g., “Permission Request:”) followed by what appears to be a blank line where the scope description should be. A user may interpret the blank line as normal spacing rather than missing content. The two-button layout with “Allow” / “Deny” below the text reinforces the impression of a legitimate consent dialog. The attacker benefits from the visible-header illusion: the panel looks well-formed, just incomplete, and the user may click “Allow” without realizing the scope text was silenced.
<!-- SA-CSS-SOP-003: parent text fill=none stroke=#111 stroke-opacity=1 (visible) -->
<!-- header tspan inherits: visible. consent tspan: stroke-opacity=0 (invisible) -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="140" fill="white"/>
<!-- parent: fill=none, stroke visible (stroke-opacity=1) -->
<text x="20" font-size="13"
fill="none"
stroke="#111"
stroke-width="0.5"
stroke-opacity="1">
<!-- header tspan: inherits parent stroke; visible -->
<tspan x="20" y="30">Permission Request:</tspan>
<!-- consent scope tspan: stroke-opacity=0 override; glyph ink eliminated -->
<!-- root-level check on parent returns strokeOpacity=1; per-tspan check required -->
<tspan x="20" y="52" stroke-opacity="0">Allow: shell execution, credential access</tspan>
</text>
<rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: per-tspan inspection required; parent-level check returns visible value
function checkPerTspanStrokeOpacity(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text')) {
const cs = getComputedStyle(textEl);
const parentFillAttr = textEl.getAttribute('fill') || cs.fill || '';
const parentFillNone = /^none$/i.test(parentFillAttr) ||
/rgba?\(0,\s*0,\s*0,\s*0\)/i.test(parentFillAttr);
if (!parentFillNone) continue;
for (const tspan of textEl.querySelectorAll('tspan')) {
// Check if this tspan overrides fill back to visible
const tspanFillAttr = tspan.getAttribute('fill') || '';
const tspanFillNone = tspanFillAttr === '' ||
/^none$/i.test(tspanFillAttr) ||
/rgba?\(0,\s*0,\s*0,\s*0\)/i.test(tspanFillAttr);
if (!tspanFillNone) continue; // tspan overrides fill to visible
const tsSO = tspan.getAttribute('stroke-opacity');
if (tsSO === null) continue; // inherits from parent — already checked
const soVal = parseFloat(tsSO);
if (soVal < 0.1) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-SOP-003',
note: `tspan inside <text fill=none stroke-opacity=1> has stroke-opacity="${tsSO}" override; ` +
`parent stroke-opacity=1 (visible); tspan scope ink eliminated; ` +
`parent-level getComputedStyle returns strokeOpacity=1 — per-tspan inspection required; ` +
`visible header tspan creates illusion of functional consent panel; ` +
`tspan textContent="${tspan.textContent.trim().slice(0,30)}..."`
});
}
}
}
return findings;
}
HIGH — SA-CSS-SOP-003: A tspan inside a fill=none parent overrides stroke-opacity="0", silencing the sole rendering channel for that text span. The parent text element’s computed strokeOpacity remains 1 — a root-level scanner reports the text as visible. Per-tspan inspection is required: iterate each tspan child, check for stroke-opacity attribute overrides, and apply the fill=none + stroke-opacity < 0.1 detection logic at the tspan level as well as the parent text level.
Attack 4: JS mousedown sets stroke-opacity=0 on stroke-only text, reverts at mouseup (SA-CSS-SOP-004)
In this transient injection attack, the consent text element is styled with fill="none" and a visible stroke (stroke="#111") at rest, with no explicit stroke-opacity attribute — so the default value of 1 applies and the glyph outlines are rendered normally. The text is fully visible to a user who reads the consent panel before clicking. A JavaScript mousedown event listener is registered on the SVG element using the capture phase (addEventListener('mousedown', handler, true)). When the user’s mouse button is pressed, the handler executes before the click event fires and sets stroke-opacity="0" on the consent text element via setAttribute. The stroke becomes transparent; the fill is already none; the text disappears at the exact moment the click is initiated. At mouseup, a second handler removes the stroke-opacity attribute, restoring the default of 1 and making the text visible again.
The visual window of invisibility is precisely the click interval — from mouse button depression to mouse button release. Human reaction time and visual processing during intentional motor actions (clicks) means users are unlikely to notice a sub-100ms visual change during the button press itself. The consent text is visible before the click and immediately after, but invisible during the actual consent action. The user believes they read the full consent text (which was visible just before they pressed the button) and clicked “Allow” with full knowledge, but the text they saw moments before may not reflect the actual scope that was authorized.
Static analysis of the SVG DOM does not reveal this attack: at rest, the stroke-opacity attribute is absent, which is correct behavior. The malicious code is in a script block. A scanner must simulate user interaction — dispatching a synthetic mousedown event on the SVG — and observe attribute mutations on SVG text and tspan elements during and after the event. A MutationObserver configured to watch the stroke-opacity attribute (and style attribute for inline CSS form) on all descendant text elements, combined with a synthetic interaction test, is the detection mechanism. Note that the attack may also use an inline style (el.style.strokeOpacity = '0'), which sets the CSS property form rather than the SVG presentation attribute. Both attribute and style mutations must be observed.
<!-- SA-CSS-SOP-004: at rest fill=none, stroke=#111, no stroke-opacity (defaults 1) -->
<!-- mousedown: stroke-opacity=0 injected; stroke-only text disappears during click -->
<svg id="consent-svg-sop" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- At rest: no stroke-opacity attr; defaults to 1; glyph outlines visible -->
<text id="scope-text-sop" x="20" y="40" font-size="13"
fill="none"
stroke="#111"
stroke-width="0.5">
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-sop');
const text = document.getElementById('scope-text-sop');
svg.addEventListener('mousedown', () => {
// stroke-only text: eliminating stroke-opacity kills the sole rendering channel
text.setAttribute('stroke-opacity', '0');
}, true);
svg.addEventListener('mouseup', () => {
text.removeAttribute('stroke-opacity');
}, true);
})();
</script>
// Detection: MutationObserver on stroke-opacity attr + style on SVG text/tspan
// Combined with synthetic mousedown simulation to trigger the handler
function watchStrokeOpacityInject(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
const attr = mut.attributeName;
if (attr !== 'stroke-opacity' && attr !== 'style') continue;
const el = mut.target;
if (el.tagName !== 'text' && el.tagName !== 'tspan') continue;
// Check fill=none (stroke is sole channel)
const cs = getComputedStyle(el);
const fillCSS = cs.fill || el.getAttribute('fill') || '';
const isFillNone = /^none$/i.test(fillCSS) ||
/rgba?\(0,\s*0,\s*0,\s*0\)/i.test(fillCSS);
if (!isFillNone) continue;
const soAttr = el.getAttribute('stroke-opacity');
const soCSS = el.style.strokeOpacity;
const soVal = parseFloat(soAttr ?? soCSS ?? '1');
if (soVal < 0.1) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-SOP-004',
note: `Dynamic stroke-opacity="${soVal}" on <${el.tagName}> with fill=none; ` +
`stroke is sole rendering channel; opacity=0 during click window; ` +
`removed at mouseup; static SVG shows no stroke-opacity attr at rest; ` +
`MutationObserver on stroke-opacity attr + style attr required; ` +
`also watch CSS property form (el.style.strokeOpacity); ` +
`textContent="${el.textContent.trim().slice(0,30)}..." intact`
});
}
}
});
observer.observe(svgEl, {
subtree: true, attributes: true, attributeOldValue: true,
attributeFilter: ['stroke-opacity', 'style']
});
// Trigger detection by dispatching synthetic mousedown
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-SOP-004: JavaScript sets stroke-opacity="0" at mousedown on a fill=none text element, eliminating the sole glyph-ink channel during the click window, then removes it at mouseup. Static SVG DOM shows no stroke-opacity attribute at rest. Detection requires a MutationObserver watching both the stroke-opacity presentation attribute and the style attribute (inline CSS form) on SVG text and tspan elements, combined with synthetic interaction simulation to trigger the mousedown handler.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-SOP-001: fill=none + stroke + stroke-opacity=0 — sole channel eliminated | fill=none makes stroke the only ink source; stroke-opacity=0 removes it; getComputedStyle(el).stroke returns visible color but strokeOpacity=0 | textContent; fill-color contrast check; BCR; stroke color check; must check fill=none AND stroke-opacity < 0.1 together | Critical |
| SA-CSS-SOP-002: stroke-opacity=0.02 — near-zero on stroke-only text | 2% opacity on stroke-only text; effective pixel color near-white on white; boolean strokeOpacity>0 check passes; threshold <0.1 required | textContent; boolean opacity>0 check; stroke color check; threshold <0.1 normalized to float required | Critical |
| SA-CSS-SOP-003: per-tspan stroke-opacity=0, parent visible stroke | Parent fill=none stroke-opacity=1 (visible); header tspan inherits; consent tspan overrides stroke-opacity=0; parent-level computed check returns 1 | textContent; parent-level strokeOpacity check; per-tspan inspection with fill inheritance tracking required | High |
| SA-CSS-SOP-004: mousedown sets stroke-opacity=0, reverts mouseup | fill=none text at rest has no stroke-opacity (default 1, visible); mousedown injects stroke-opacity=0; sole channel eliminated during click; removed at mouseup | Static SVG scan; must watch stroke-opacity attr + style attr with MutationObserver + synthetic interaction simulation | Critical |