Security Guide
MCP server SVG whitespace-only consent security — zero-width space substitution produces non-zero textContent.length with zero rendered glyphs, xml:space="default" collapses all-whitespace text to nothing, soft-hyphen flooding, and textContent.trim() bypass with non-breaking content
Consent scanners that guard against invisible SVG text often check whether a text element’s textContent property is non-empty. Several classes of Unicode characters produce a non-zero textContent.length while contributing zero visible glyph width: zero-width space (U+200B), zero-width non-joiner (U+200C), zero-width joiner (U+200D), and soft hyphen (U+00AD, visible only when it falls at a line break, invisible otherwise). An SVG text element whose content consists entirely of these characters has a non-zero textContent.length, a getBoundingClientRect() that may return a tiny but technically non-zero width, and renders nothing that a human can read. A second class of attacks exploits the SVG xml:space="default" attribute, which collapses all inter-element whitespace — a text element with only spaces and newlines becomes empty in the rendered output while its raw DOM text node contains visible-length characters. Four attack patterns: zero-width space substitution, soft-hyphen flooding, xml:space="default" collapse, and mixed-content zero-width injection mid-string.
Attack 1: zero-width space (U+200B) substitution — consent text replaced with 40 zero-width spaces, textContent.length = 40 (SA-CSS-WSP-001)
The Unicode character U+200B (ZERO WIDTH SPACE) is a word-boundary hint that carries no glyph width and produces no visible pixel in any standard font. An attacker replaces the consent string “Allow: shell execution, credential access” (38 characters) with 38 consecutive U+200B code points. The text element’s textContent property returns a 38-character string. textContent.length returns 38. textContent.trim() also returns a 38-character string (U+200B is not in the Unicode whitespace set that String.prototype.trim() removes in older ECMAScript environments; behavior varies). getBoundingClientRect().width returns approximately 0 (some browsers return a sub-pixel non-zero value). No glyph is rendered.
A scanner that guards against empty text elements with if (textEl.textContent.trim() === ’’) { flag(); } may miss this attack: the trimmed string is either 38 zero-width spaces (not empty) or 38 stripped characters (empty only in environments where trim removes U+200B). The reliable detection strategy is to strip all Unicode zero-width and invisible characters before the non-empty check, and to verify that the BCR width exceeds a minimum threshold.
<!-- SA-CSS-WSP-001: consent text replaced with 38 U+200B zero-width spaces -->
<!-- textContent.length = 38; textContent.trim() may = '' or 38 ZWS (browser-dependent) -->
<!-- getBoundingClientRect().width ≈ 0; zero rendered glyph pixels -->
<!-- fill="#111" (plausible fill); opacity=1 (no opacity hiding); BCR height non-zero -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- U+200B × 38: textContent.length=38; textContent="..." (not "" after trim in some envs) -->
<!-- zero glyph advance width; BCR width ≈ 0; rendered pixel count = 0 -->
<text x="20" y="50" font-size="13" fill="#111"></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: strip invisible Unicode then check non-empty; check BCR width
const INVISIBLE_CHARS_RE = /[ ----
----]/g;
function checkWhitespaceOnlyConsent(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const rawText = textEl.textContent ?? '';
const strippedText = rawText.replace(INVISIBLE_CHARS_RE, '').trim();
const bcr = textEl.getBoundingClientRect();
// Non-empty raw but empty after stripping → zero-width substitution
if (rawText.trim().length > 0 && strippedText.length === 0) {
const zwCount = (rawText.match(//g) ?? []).length;
const adCount = (rawText.match(//g) ?? []).length;
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-WSP-001',
note: `SVG <${textEl.tagName}> textContent.length=${rawText.length}; ` +
`strippedText.length=0 (all invisible Unicode); ` +
`U+200B count=${zwCount}; U+00AD count=${adCount}; ` +
`BCR w=${bcr.width.toFixed(2)}px h=${bcr.height.toFixed(2)}px; ` +
`fill="${textEl.getAttribute('fill')}"; opacity=1; ` +
`textContent.length-based non-empty guard BYPASSED; ` +
`detection: strip invisible Unicode chars before non-empty check; ` +
`also check BCR width > min_threshold (e.g. 10px for 38-char string at 13px)`
});
}
}
return findings;
}
CRITICAL — SA-CSS-WSP-001: Consent text replaced with 38 U+200B zero-width spaces produces textContent.length = 38 while rendering zero glyphs. A scanner guarding with textContent.trim() !== ’’ may pass this check because U+200B is not always removed by trim(). Detection requires stripping all invisible Unicode characters (U+200B–U+200F, U+00AD, U+FEFF, and others) before the non-empty check, and independently verifying that the BCR width exceeds a threshold proportional to the expected character count.
Attack 2: soft hyphen (U+00AD) flooding — 40 soft hyphens render only at line breaks; mid-line → zero ink (SA-CSS-WSP-002)
The Unicode soft hyphen (U+00AD, SOFT HYPHEN) is a conditional glyph: it is visible only when a line-breaking algorithm breaks a line at that position, in which case a hyphen glyph is rendered. When no line break occurs at the soft hyphen position — which is the case for all characters in a non-wrapping SVG text element — the soft hyphen produces no glyph and zero advance width. An SVG <text> element does not wrap (SVG text is single-line by default unless a text wrapping container like <foreignObject> is used). Therefore, a text element consisting entirely of U+00AD characters renders nothing.
The attack is particularly insidious because soft hyphens are valid, commonly used Unicode characters in multilingual text. A scanner that checks for consent-keyword presence via textContent will find nothing (the string contains no English words). But if the attacker structures the soft-hyphen sequence to simulate the byte count of a consent string for length-based validation, the scanner’s non-empty length guard passes. The rendered output is empty regardless of the soft-hyphen count.
<!-- SA-CSS-WSP-002: consent text replaced with U+00AD (soft hyphen) sequence -->
<!-- SVG text does not wrap (single-line); soft hyphens only visible at line-break positions -->
<!-- no line breaks → zero glyph advance width for all soft hyphens → invisible -->
<!-- textContent.length = 40 (40 soft hyphens); trim() returns 40-char string (U+00AD not whitespace) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- U+00AD × 40: no line-break → all conditional hyphens invisible; zero pixels -->
<!-- textContent contains 40 soft hyphens; BCR.width ≈ 0 -->
<text x="20" y="50" font-size="13" fill="#111"></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 for soft-hyphen-only or zero-width-only content + BCR width threshold
function checkSoftHyphenFlooding(svgEl) {
const findings = [];
// U+00AD count > X in a text element that is supposed to show consent text is suspicious
const SOFT_HYPHEN = '';
const ZERO_WIDTH = /[]/g;
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const raw = textEl.textContent ?? '';
if (raw.length === 0) continue;
const softHyphenCount = (raw.match(//g) ?? []).length;
const zwCount = (raw.match(ZERO_WIDTH) ?? []).length;
const visibleChars = raw.replace(INVISIBLE_CHARS_RE, '').trim();
const bcr = textEl.getBoundingClientRect();
// Threshold: if >50% of chars are invisible code points and BCR width is tiny
const invisibleRatio = (softHyphenCount + zwCount) / raw.length;
if (invisibleRatio > 0.5 && bcr.width < 5) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-WSP-002',
note: `SVG <${textEl.tagName}> textContent.length=${raw.length}; ` +
`U+00AD (soft hyphen) count=${softHyphenCount}; ` +
`zero-width count=${zwCount}; invisible ratio=${(invisibleRatio*100).toFixed(0)}%; ` +
`visibleChars.length=${visibleChars.length}; ` +
`BCR w=${bcr.width.toFixed(2)}px (< 5px threshold); ` +
`SVG text does not wrap → soft hyphens never activated → zero glyph advance; ` +
`detection: count U+00AD in SVG text elements; flag ratio > threshold; ` +
`also check BCR.width vs expected width for stated char count`
});
}
}
return findings;
}
CRITICAL — SA-CSS-WSP-002: Soft hyphen (U+00AD) characters are conditional glyphs — they render only at line-break positions. SVG text elements are single-line by default and never trigger line breaks, so all soft hyphens remain invisible. A text element of 40 soft hyphens has textContent.length = 40 and trim() returns the 40-character string (U+00AD is not a whitespace character). Detection requires checking the ratio of invisible code points to total text length and verifying that the BCR width is commensurate with the claimed character count.
Attack 3: xml:space="default" collapses all-whitespace text to empty rendering (SA-CSS-WSP-003)
The SVG specification defines two whitespace handling modes via the xml:space attribute. The default mode (xml:space="default") strips leading and trailing whitespace from each text chunk and collapses all internal whitespace sequences to a single space. A text element that contains only spaces, newlines, and tabs becomes empty in the rendered output under this mode. The raw DOM text node still contains the whitespace characters, so textContent returns a non-empty string of spaces. textContent.trim() returns an empty string, but the attacker can pad with non-breaking spaces (U+00A0) to defeat the trim check while still collapsing under xml:space="default" rules in certain contexts.
This attack is relevant when an MCP server constructs consent UI by injecting text content into SVG elements and the text content happens to be whitespace-normalized to nothing. More commonly it is exploited deliberately: the attacker fills the text content with whitespace that looks like padding but collapses to empty on render while preserving a non-trivial textContent length check.
<!-- SA-CSS-WSP-003: xml:space="default" collapses all-whitespace text to empty render -->
<!-- text content = " \n\t\n " (12 chars); render = " " (collapsed to single space in some modes) -->
<!-- or under strict default mode: stripped to "" (empty) -->
<!-- textContent.trim() = "" (whitespace only); non-empty check FAILS → scanner may catch this -->
<!-- but non-breaking spaces (U+00A0) survive trim AND collapse to visible single space in xml:space -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg" xml:space="default">
<rect width="440" height="120" fill="white"/>
<!-- whitespace-only: rendered as empty (collapsed); textContent has whitespace chars -->
<!-- xml:space=default: leading/trailing stripped, internal collapsed to single space -->
<text x="20" y="50" font-size="13" fill="#111"
xml:space="default">
</text>
<!-- harder variant: U+00A0 (non-breaking spaces) — not stripped by xml:space rules, -->
<!-- not stripped by JS trim(); BCR.width = non-zero (NBSP has glyph advance); but -->
<!-- visually identical to empty (NBSP on white background = white space) -->
<text x="20" y="60" font-size="13" fill="white"> </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: xml:space=default whitespace collapse + non-breaking space as visible-but-blank
function checkXmlSpaceCollapse(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const raw = textEl.textContent ?? '';
const xmlSpace = textEl.getAttribute('xml:space') ||
textEl.closest('[xml\\:space]')?.getAttribute('xml:space') || 'default';
// Under xml:space=default, strip leading/trailing whitespace and collapse internals
const collapsed = (xmlSpace === 'default')
? raw.replace(/[\t\n\r ]+/g, ' ').trim()
: raw;
// Non-breaking space U+00A0 with white fill: text has width but no visible ink
const nbspOnly = /^[ ]+$/.test(raw.trim());
const whiteFill = textEl.getAttribute('fill') === 'white' ||
getComputedStyle(textEl).fill?.includes('255, 255, 255');
if (collapsed.length === 0 && raw.trim().length > 0) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-WSP-003',
note: `SVG <${textEl.tagName}> textContent="${raw.slice(0,20)}..." (length=${raw.length}); ` +
`xml:space="${xmlSpace}"; collapsed="${collapsed}" (empty after strip); ` +
`textContent.length=${raw.length} (non-zero, non-empty guard bypassed); ` +
`xml:space=default strips leading/trailing whitespace and collapses internals; ` +
`rendered output is empty or single-space; ` +
`detection: apply same whitespace collapse rule to textContent before non-empty check`
});
}
if (nbspOnly && whiteFill) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-WSP-003b',
note: `SVG <${textEl.tagName}> contains only non-breaking spaces (U+00A0 etc.); ` +
`fill=white on white background: glyph advance present but invisible; ` +
`textContent="${raw.slice(0,20)}..." length=${raw.length}; ` +
`trim() = "${raw.trim().slice(0,10)}..." (non-empty: U+00A0 not removed by trim); ` +
`BCR.width non-zero (NBSP has advance); visual result = blank white strip; ` +
`detection: test fill color against background; flag white-on-white text`
});
}
}
return findings;
}
HIGH — SA-CSS-WSP-003: xml:space="default" collapses whitespace-only text content to an empty or single-space rendered string. The raw DOM textContent contains whitespace characters with non-zero length. The non-breaking space variant (U+00A0) with fill="white" on a white background has a non-zero BCR width and survives trim() but is visually indistinguishable from empty space. Detection must apply the same whitespace-collapse rules to textContent as the SVG renderer, and separately test that visible text elements have sufficient contrast against their background.
Attack 4: zero-width characters injected mid-string to split consent keywords (SA-CSS-WSP-004)
Rather than replacing the entire consent string with invisible characters, this variant injects zero-width characters between the letters of consent keywords. The string “Allow” becomes “Allow” — five Latin letters with four U+200B characters interspersed. The rendered result is visually indistinguishable from “Allow” (zero-width spaces produce zero advance width). However, a scanner searching for the substring “allow” in textContent will fail to match because the lowercase “allow” pattern does not appear as a contiguous sequence in the text node. The textContent value is “Allow” — a regex /allow/i does not match unless zero-width characters are stripped first.
This attack simultaneously achieves two goals: the consent text renders visually (so human reviewers approve the UI), but automated consent-keyword scanners miss the match because their pattern doesn’t account for zero-width character injection. It is also relevant in the reverse direction: an attacker may inject zero-width characters between letters of a forbidden phrase to bypass content-moderation scanners while the text remains legible to humans.
<!-- SA-CSS-WSP-004: U+200B injected between every letter of consent keywords -->
<!-- "Allow" renders as "Allow" (zero-width = zero advance) -->
<!-- textContent contains zero-width chars mid-word: /allow/i regex FAILS to match -->
<!-- consent scanner checking for keyword "allow" in textContent MISSES -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- "Allow: shell exec" with U+200B between each char -->
<!-- rendered: "Allow: shell exec..." (visually correct); textContent: keyword split -->
<text x="20" y="50" 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>
// Detection: strip zero-width chars BEFORE keyword search in textContent
const INVISIBLE_CHARS_RE = /[ ---
----]/g;
function searchConsentKeywordsStripped(textContent) {
// Remove all invisible Unicode before keyword search
const stripped = textContent.replace(INVISIBLE_CHARS_RE, '');
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential', 'execution', 'access'];
return CONSENT_KEYWORDS.filter(k => stripped.toLowerCase().includes(k));
}
function checkMidStringZeroWidth(svgEl) {
const findings = [];
const ZW_PATTERN = /[-]/;
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const raw = textEl.textContent ?? '';
if (!ZW_PATTERN.test(raw)) continue;
const stripped = raw.replace(INVISIBLE_CHARS_RE, '');
const zwCount = (raw.match(/[-]/g) ?? []).length;
const keywords = searchConsentKeywordsStripped(raw);
const keywordsMissedByRaw = keywords.filter(k => !raw.toLowerCase().includes(k));
if (keywordsMissedByRaw.length > 0) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-WSP-004',
note: `SVG <${textEl.tagName}> zero-width chars injected mid-keyword; ` +
`U+200B/200C/200D count=${zwCount}; ` +
`stripped textContent="${stripped.slice(0,40)}..."; ` +
`keywords found after stripping: [${keywords.join(', ')}]; ` +
`same keywords in raw (not stripped): not found (mid-string injection splits tokens); ` +
`rendered text looks correct (zero-width = zero advance); ` +
`human reviewer approves; keyword scanner MISSES; ` +
`detection: strip invisible Unicode from textContent BEFORE keyword regex search`
});
}
}
return findings;
}
CRITICAL — SA-CSS-WSP-004: Zero-width spaces (U+200B) injected between every letter of consent keywords break keyword-based scanner regex patterns while leaving the rendered text visually intact. /allow/i does not match “Allow”. Detection requires stripping all invisible Unicode characters from textContent before applying any keyword or pattern search. Both the rendering-visibility check and the keyword-presence check must independently strip invisible characters to avoid false negatives.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-WSP-001: zero-width space (U+200B) substitution | 38 U+200B chars replace consent text; textContent.length=38; trim() may be non-empty; BCR.width≈0; zero glyph advance width | textContent.length non-zero guard; trim()≠'' guard in some environments; requires: strip invisible Unicode before non-empty check; check BCR.width vs threshold | Critical |
| SA-CSS-WSP-002: soft hyphen (U+00AD) flooding — SVG no-wrap context | 40 soft hyphens; visible only at line breaks; SVG text = single-line; no line break → zero advance for all; textContent.length=40; BCR.width≈0 | textContent.length guard; trim() guard (U+00AD not whitespace); requires: count U+00AD ratio; check BCR.width vs expected width for char count | Critical |
| SA-CSS-WSP-003: xml:space="default" whitespace collapse + U+00A0 white fill | Whitespace-only text collapses to empty under xml:space=default; U+00A0 with fill=white is non-empty BCR but invisible; textContent non-empty | textContent.length guard; trim() bypassed by U+00A0; requires: apply xml:space collapse rules to textContent; test fill color vs background color | High |
| SA-CSS-WSP-004: zero-width chars mid-keyword — breaks keyword regex, renders correctly | "Allow" with U+200B between each letter; rendered = "Allow" (zero advance); textContent = "Al..." (keyword not contiguous); /allow/i MISSES | Keyword regex on raw textContent; requires: strip invisible Unicode from textContent BEFORE keyword search | Critical |