MCP server CSS stroke-dasharray security: SVG consent text invisibility, character masking, and stroke-opacity attacks
Published 2026-09-26 — SkillAudit Research
CSS stroke-dasharray defines a pattern of dashes and gaps for the stroke of SVG shape outlines and text. On SVG <text> and <tspan> elements, the stroke outlines each character glyph. With a standard fill color and no stroke, characters are filled but not outlined. With fill: transparent (or fill: none) and a stroke, characters are only rendered as outlines. With fill: transparent and a stroke-dasharray that produces no visible dash, the text element occupies space and has DOM content but renders zero visible pixels.
MCP servers that render consent dialogs as SVG (a pattern used for custom-styled dialogs in WebGL contexts, canvas overlays, and SVG-based UI toolkits) can exploit this property to make consent text invisible, mask specific characters, or apply stroke patterns that obscure text in less obvious ways. The attack is invisible to DOM-content audits, which confirm the text node contains the expected consent string without verifying that the string renders visibly.
SVG text content vs rendering: SVG <text> elements with consent text pass DOM-content audits — textContent returns the expected string. They have positive bounding rects. They are not display: none. With fill: transparent and stroke-dasharray: 0, 9999, they render no visible pixels. The content is present; the rendering is absent. Consent validation that reads DOM content without checking SVG rendering properties will miss this attack entirely.
Attack findings
Setting
fill: transparent removes the fill rendering of each character glyph. Setting stroke-dasharray: 0, 9999 creates a dash pattern with a 0-length dash and a 9999-length gap — effectively no dashes at all, since the entire path is gap. The stroke outlines also render nothing. The SVG text element occupies its bounding rect in the layout but produces zero visible pixels. The DOM text node contains the consent string. getBoundingClientRect() returns a positive rect. The element is not hidden by any standard check.
<svg width="400" height="200">
<text x="10" y="30"
style="fill: transparent;
stroke: #333;
stroke-dasharray: 0, 9999;">
By clicking Accept, you agree to the above terms.
</text>
</svg>
<!-- Checks:
textContent: "By clicking Accept, you agree to the above terms." ✓ (correct)
getBoundingClientRect(): { width: 380, height: 20 } ✓ (positive)
display: block (SVG element default) ✓
visibility: visible ✓
Rendered pixels: 0 (fill:transparent + stroke:0-dash) ✗ INVISIBLE -->
SVG stroke-dasharray patterns apply along the path of each glyph outline. When the dash-gap pattern repeats at intervals matching the average glyph width, alternating characters' outlines are rendered as dashes (visible) or gaps (invisible). Combined with a semi-transparent or same-background fill, the visible dashes provide just enough of each character to suggest text is present, while the gap characters are entirely missing. A consent string like "you agree to the above terms" with every other character masked becomes visually unreadable but structurally present in the DOM.
<text style=" fill: rgba(0,0,0,0.15); /* very faint fill — barely visible */ stroke: rgba(0,0,0,0.15); stroke-width: 0.5; stroke-dasharray: 8, 8; /* 8px dash, 8px gap */ font-size: 14px; /* average char width ~8px at 14px */ "> By clicking Accept, you agree to binding arbitration. </text> <!-- At 14px font size, average ASCII character width ≈ 7-9px. stroke-dasharray: 8, 8 repeats every 16px. Roughly alternating characters fall in dash vs gap phase. Visual result: fragmented, partially legible text. "binding arbitration" may read as "bnig arirto" — key terms removed. textContent check: full string present → PASS (incorrect) -->
An SVG text element with
stroke-opacity: 0 (invisible stroke) and a large stroke-width (e.g., 20px) paints an invisible stroke band 10px wide on each side of every character glyph path. This invisible stroke can overlap adjacent SVG text elements — non-consent labels, instructions, or UI context that the user needs. The invisible stroke band sits on top of the adjacent text (via SVG painting order), making those adjacent elements invisible in the region where the stroke overlaps. Consent text is untouched; context around it is obscured. The invisible stroke is undetectable via opacity checks (it is zero-opacity) and does not appear as an element of its own.
<svg>
<text y="20" style="font-size:14px">
Permission list header: This skill requests:
</text>
<text y="40"
style="fill: #333;
stroke: #fff; /* same as background */
stroke-width: 20px;
stroke-opacity: 0;"> /* invisible but occupies paint space */
By clicking Accept, you agree to the above terms.
</text>
</svg>
<!-- The consent text renders normally (fill:#333).
The 20px stroke is stroke-opacity:0 — invisible.
But in SVG painting order, this zero-opacity stroke covers
±10px around every character of the consent text.
If positioned to overlap the header line (y=20 vs y=40 with large font),
the invisible stroke band can obscure the header text visually.
The header text element still exists in the DOM. -->
The SVG
pathLength attribute on a <text> element sets a user-specified total path length that overrides the browser's computed path length for dash scaling purposes. With pathLength="100", a stroke-dasharray: 10, 90 means "10% dash, 90% gap" relative to the user-defined 100-unit length, regardless of the actual character path length. This allows precise control over which portion of the consent text's path falls in a dash (visible) vs gap (invisible) phase. An MCP server can calculate the exact dasharray values to ensure specific words — the key legal terms in the acceptance clause — fall in the gap phase and are not rendered as stroke outlines.
<!-- pathLength scales the coordinate system for dasharray -->
<text pathLength="100"
style="fill: transparent;
stroke: #333;
stroke-width: 1;
/* dash: 50-unit gap (first half = invisible), then 50-unit dash (second half) */
stroke-dasharray: 0 50, 50 0;">
By clicking Accept, you agree to binding arbitration in Delaware.
</text>
<!-- With pathLength=100:
First half of path (chars: "By clicking Accept, you agree to") → gap → invisible
Second half (chars: "binding arbitration in Delaware") → dash → visible
Result: users see only "binding arbitration in Delaware" without the
"By clicking Accept, you agree to" introduction establishing consent.
textContent check: full string → PASS (attack invisible to DOM checks) -->
Detection
function checkStrokeDasharray(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 stroke = cs.stroke || '';
const sda = cs.strokeDasharray || '';
const sOp = parseFloat(cs.strokeOpacity || '1');
const fOp = parseFloat(cs.fillOpacity || '1');
const sw = parseFloat(cs.strokeWidth || '1');
const fillInvisible = fill === 'none' || fill === 'transparent' ||
fill === 'rgba(0, 0, 0, 0)' || fOp === 0;
const strokeInvisible = stroke === 'none' || stroke === 'transparent' ||
stroke === 'rgba(0, 0, 0, 0)' || sOp === 0;
/* Both fill and stroke invisible → zero rendered pixels */
if (fillInvisible && strokeInvisible) {
findings.push({ severity: 'high', el, issue: 'SVG text: fill and stroke both transparent — zero rendered pixels, consent text invisible' });
continue;
}
/* Dasharray with fill-invisible → check if dasharray produces any dash */
if (fillInvisible && sda && sda !== 'none') {
const parts = sda.split(/[\s,]+/).map(Number).filter(n => !isNaN(n));
const dashLengths = parts.filter((_, i) => i % 2 === 0);
const allZero = dashLengths.every(d => d === 0);
if (allZero) {
findings.push({ severity: 'high', el, issue: `SVG text: fill:transparent + stroke-dasharray with all-zero dashes — no rendered stroke; text invisible` });
} else if (parts.length >= 2) {
/* Check if alternating pattern could mask characters */
const avgGap = parts.filter((_, i) => i % 2 === 1).reduce((a, b) => a + b, 0) /
parts.filter((_, i) => i % 2 === 1).length;
if (avgGap >= 4) {
findings.push({ severity: 'medium', el, issue: `SVG text: fill:transparent + stroke-dasharray with ${avgGap.toFixed(0)}px gaps — may mask alternating characters at typical font sizes` });
}
}
}
/* Large invisible stroke width overpainting adjacent content */
if (sw > 10 && sOp === 0) {
findings.push({ severity: 'medium', el, issue: `SVG text: stroke-opacity:0 + stroke-width:${sw}px — invisible ${sw}px stroke band may overpaint adjacent SVG content` });
}
}
return findings.length ? findings : null;
}
Remediation
| Control | How it helps |
|---|---|
Check SVG <text> elements for fill: transparent/none combined with stroke-dasharray | When fill is invisible, the stroke dasharray is the only rendering mechanism; a zero-dash pattern produces zero visible pixels despite full DOM content |
Parse stroke-dasharray values and check whether any dash-length values are non-zero | A dasharray of 0, 9999 is syntactically valid and produces no visible rendering; dash-length = 0 is the zero-visibility indicator |
Check pathLength attribute on SVG text elements with non-trivial dasharray values | pathLength changes the coordinate system used to scale the dasharray, enabling precise targeting of specific words within the consent string |
Flag large stroke-width values combined with stroke-opacity: 0 | An invisible stroke of 20px+ creates an overpaint band across adjacent content; the attack is entirely in the stroke properties, not visible in fill or element bounds |
SkillAudit audits SVG consent rendering — checking fill/stroke transparency, dasharray zero-dash patterns, pathLength manipulations, and invisible overpaint strokes — in addition to HTML consent element checks. Run a free audit on any MCP server GitHub URL.