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 “A​l​l​o​w” — 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 “A​l​l​o​w” — 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 -->
<!-- "A​l​l​o​w" 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"/>

  <!-- "A​l​l​o​w​:​ ​s​h​e​l​l​ ​e​x​e​c" 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">A​l​l​o​w​:​ ​s​h​e​l​l​ ​e​x​e​c​u​t​i​o​n​,​ ​c​r​e​d​e​n​t​i​a​l​ ​a​c​c​e​s​s</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 “A​l​l​o​w”. 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

AttackMechanismWhat it bypassesSeverity
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 "A​l​l​o​w" with U+200B between each letter; rendered = "Allow" (zero advance); textContent = "A​l..." (keyword not contiguous); /allow/i MISSES Keyword regex on raw textContent; requires: strip invisible Unicode from textContent BEFORE keyword search Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-WSP-001: SVG <text> consent text replaced with U+200B (zero-width space) characters — 38 consecutive U+200B code points replace the consent string; U+200B has zero advance width and no glyph; textContent property returns a 38-character string (non-zero length guard bypassed); textContent.trim() behavior varies by JavaScript engine — U+200B is not in the ECMAScript whitespace set in older specs but may be stripped in modern engines; getBoundingClientRect().width returns approximately 0 (sub-pixel values possible); no visible consent text is rendered; fill="#111" and opacity=1 (CSS invisibility scanners pass); detection requires stripping all invisible Unicode characters (U+200B, U+200C, U+200D, U+FEFF, U+00AD, and related ranges) from textContent before the non-empty guard, and independently verifying that BCR.width exceeds a minimum threshold proportional to the expected character count.
CRITICAL SA-CSS-WSP-002: SVG <text> flooded with U+00AD (soft hyphen) — soft hyphen is a conditional glyph that renders a hyphen character only when the line-breaking algorithm places a break at that position; SVG text elements are single-line by default (no text wrapping container); no line breaks occur; all soft hyphens produce zero glyph advance; textContent.length = 40 (40 soft hyphens); String.prototype.trim() does not remove U+00AD (it is not a Unicode whitespace code point); getBoundingClientRect().width ≈ 0; detection requires counting U+00AD occurrences in SVG text elements and flagging ratios above threshold, combined with a BCR width check to confirm near-zero advance width for the claimed character count.
HIGH SA-CSS-WSP-003: xml:space=default whitespace collapse — SVG xml:space="default" mode strips leading and trailing whitespace from each text chunk and collapses all internal whitespace sequences to a single space; a text element containing only spaces, tabs, and newlines collapses to an empty or single-space rendered string; the DOM textContent still contains the raw whitespace characters with non-zero length; the non-breaking space variant (U+00A0 with fill=white on a white SVG background) has non-zero BCR.width and survives String.prototype.trim() but is visually indistinguishable from blank space; detection must apply the same xml:space collapse rules to textContent as the SVG renderer applies to the text content, and must test fill color against the background color for text elements that would otherwise be invisible due to color matching.
CRITICAL SA-CSS-WSP-004: zero-width space injection mid-keyword — U+200B characters injected between every letter of consent keywords (e.g., "A​l​l​o​w") split the keyword into non-contiguous character sequences; the SVG renderer produces zero advance for each U+200B, so the rendered text appears correct ("Allow"); a keyword scanner applying /allow/i against the raw textContent finds no match because the contiguous substring "allow" is absent (zero-width chars break the sequence); the attack combines visual correctness for human reviewers with keyword invisibility for automated scanners; detection requires stripping all invisible Unicode characters from textContent before any keyword or pattern matching operation.

Related: SVG unicode-bidi consent attacks  |  SVG fill=none stroke=none consent attacks  |  SVG visibility consent attacks

← Blog  |  Security Checklist