Security Guide

MCP server SVG word-spacing consent security — word-spacing=300 adds 300px after every space in the consent string disconnecting words into isolated tokens, extreme negative word-spacing=-20 causes all words to overlap into an unreadable dense mass, per-tspan scope with visible header, and mousedown injection

The SVG word-spacing presentation attribute adds additional advance after each ASCII space character (U+0020) in the text content. Unlike letter-spacing which applies to every glyph, word-spacing only fires at word boundaries. For the consent string “Allow: shell execution, credential access” there are five space characters, producing five word gaps. word-spacing=300 adds 300px after each of those five spaces: individual words remain legible as isolated tokens, but the sentence is spread across 5×300+natural_width ≈ 1660px — far beyond a 440px viewport. The user sees at most the first two words; the permission scope “credential access” and much of “execution” are clipped. The textContent DOM property returns the full string; keyword checks pass. Four attack patterns: extreme positive word-spacing disconnects sentence, extreme negative overlaps all words, per-tspan scope with normal header, and transient mousedown injection.

Attack 1: word-spacing=300 adds 300px after every space — consent sentence disconnected, scope words clipped (SA-CSS-WSP-001)

The SVG word-spacing attribute adds additional advance at each word boundary. The consent string “Allow: shell execution, credential access” contains five space characters. With word-spacing=300 each space expands from its normal ≈4px advance to 304px. The layout proceeds as follows: “Allow:” renders at x=20 with natural width approximately 42px; the space after it becomes 304px wide, so “shell” starts at x=20+42+304=366; “shell” renders approximately 28px wide, ending at x=394; the next space is 304px wide so “execution,” starts at x=698 — well past the 440px right viewport edge. All content from “execution,” onward is clipped. Individual words are legible: “Allow:” at the left and “shell” near the right edge are readable. But the consent sentence as a continuous string is destroyed — the user cannot parse “Allow: [300px gap] shell [300px gap] execution, [invisible]” as a meaningful statement granting permission for “shell execution, credential access”. The textContent of the element returns the full string; BCR returns the SVG container bounds. A scanner checking only getComputedStyle(el).wordSpacing may or may not reflect the SVG presentation attribute depending on the browser; checking both the attribute and computed style is necessary.

<!-- SA-CSS-WSP-001: word-spacing=300 — 300px gap after each space; sentence disconnected -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- word-spacing=300: each space becomes ~304px wide -->
  <!-- "Allow:" at x=20; "shell" starts at x≈366; "execution," starts at x≈698 (off-screen) -->
  <text x="20" y="40" font-size="13" fill="#111"
        word-spacing="300">
    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: check word-spacing attribute and computed style for extreme positive values
function checkWordSpacingLarge(svgEl) {
  const findings = [];
  for (const el of svgEl.querySelectorAll('text, tspan')) {
    const attrVal = el.getAttribute('word-spacing');
    const cssVal  = getComputedStyle(el).wordSpacing;
    const attrNum = attrVal !== null ? parseFloat(attrVal) : NaN;
    const cssNum  = cssVal ? parseFloat(cssVal) : NaN;
    const val = !isNaN(attrNum) ? attrNum : cssNum;
    if (!isNaN(val) && val > 100) {
      const text = el.textContent.trim();
      const spaceCount = (text.match(/ /g) || []).length;
      const estimatedExtraWidth = spaceCount * val;
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-WSP-001',
        note: `<${el.tagName}> word-spacing="${attrVal ?? cssVal}" (${val}px extra per space); ` +
              `${spaceCount} spaces in text → ${estimatedExtraWidth}px of extra width; ` +
              `consent sentence disconnected; scope words after 2nd word gap clipped beyond viewport; ` +
              `textContent="${text.slice(0,30)}..." intact; DOM keyword check passes`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-WSP-001: word-spacing=300 inserts 300px of extra advance after each of the five spaces in the consent string. “Allow:” and “shell” appear as isolated visible tokens; all permission terms from “execution,” onward are clipped beyond the viewport. textContent and BCR return normal values. Detection requires checking both getAttribute('word-spacing') and getComputedStyle(el).wordSpacing with threshold >100px.

Attack 2: word-spacing=-20 extreme negative — all words overlap into unreadable dense mass (SA-CSS-WSP-002)

A negative word-spacing value reduces the advance at word boundaries below the normal space character width. At word-spacing=-20, each space character has its advance reduced by 20px. The natural advance of a space character at 13px font is approximately 4px, so the effective space advance becomes 4-20 = -16px. Each word’s starting position is 16px to the left of the previous word’s end. After “Allow:” ends at approximately x=62, “shell” starts at x=62-16=46 — directly on top of the last few characters of “Allow:”. After “shell” ends at approximately x=74, “execution,” starts at x=58 — overlapping with “Allow:” again. The entire sentence collapses back onto the first word’s horizontal span. The visual result is a dense overlap of all word glyphs stacked in approximately the same x-range, producing an unreadable dense glyph mass. Unlike the alternating-dx or letter-spacing negative overlap patterns, this attack targets only word boundaries, so within each word the characters are correctly spaced — individual three-character word fragments may appear legible at the extreme left and right edges of the overlap zone, but no complete word or sentence is parseable.

<!-- SA-CSS-WSP-002: word-spacing=-20 — negative space advance; all words overlap -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- word-spacing=-20: space advance = 4 - 20 = -16px -->
  <!-- "Allow:" ends at x≈62; "shell" starts at x≈46 (overlaps "low:"); etc. -->
  <!-- Dense overlapping word glyph mass; no sentence readable -->
  <text x="20" y="40" font-size="13" fill="#111"
        word-spacing="-20">
    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: flag extreme negative word-spacing that causes word overlap
function checkWordSpacingNegative(svgEl) {
  const findings = [];
  for (const el of svgEl.querySelectorAll('text, tspan')) {
    const attrVal = el.getAttribute('word-spacing');
    const cssVal  = getComputedStyle(el).wordSpacing;
    const attrNum = attrVal !== null ? parseFloat(attrVal) : NaN;
    const cssNum  = cssVal ? parseFloat(cssVal) : NaN;
    const val = !isNaN(attrNum) ? attrNum : cssNum;
    if (!isNaN(val) && val < -6) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-WSP-002',
        note: `<${el.tagName}> word-spacing="${attrVal ?? cssVal}" (${val}px); ` +
              `negative space advance (natural ~4px; effective ${4 + val}px); ` +
              `all words rendered with backward displacement at word boundaries; ` +
              `words overlap into dense unreadable mass at text origin; ` +
              `textContent intact but no word or sentence parseable visually`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-WSP-002: word-spacing=-20 reduces the space-character advance to -16px, causing each word to start 16px to the left of the previous word’s end. All words of the consent sentence collapse into a dense overlapping glyph mass at the starting x position. Detection requires flagging negative word-spacing values where the absolute value exceeds the natural space advance (threshold: <−6px).

Attack 3: per-tspan scope word-spacing=300 with normal header — root-level check insufficient (SA-CSS-WSP-003)

The root <text> element has no word-spacing attribute. The header <tspan> “Allow:” renders with normal word spacing. Only the scope <tspan> carrying the permission terms has word-spacing=300. Because word-spacing is an inheritable SVG presentation attribute, the scope tspan overrides the inherited value for its own content only. The header text “Allow:” is a single token with no internal space, so it is unaffected regardless. The scope text “shell execution, credential access” has four spaces; with word-spacing=300 each gap expands to 304px. The scope content spreads over 4×300+natural_width ≈ 1250px. Starting from where the header ends (approximately x=62), the scope string immediately exits the 440px viewport after “shell” + 304px gap — “execution,” is entirely off-screen. A scanner checking only the root <text> element returns clean; per-tspan inspection is required. This pattern makes the attack especially deceptive: the header word “Allow:” is visible and normally spaced; a human or shallow scanner might conclude the consent text is intact.

<!-- SA-CSS-WSP-003: per-tspan scope word-spacing=300; root text element clean -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- text: no word-spacing (root check returns clean) -->
  <text x="20" y="40" font-size="13" fill="#111">
    <!-- header: no word-spacing — "Allow:" renders normally -->
    <tspan>Allow: </tspan>
    <!-- scope: word-spacing=300 — "shell [300px] execution, [300px] credential [300px] access" -->
    <tspan word-spacing="300">shell execution, credential access</tspan>
  </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: inspect every tspan independently for word-spacing
function checkWordSpacingPerTspan(svgEl) {
  const findings = [];
  for (const tspan of svgEl.querySelectorAll('tspan')) {
    const attrVal = tspan.getAttribute('word-spacing');
    const cssVal  = getComputedStyle(tspan).wordSpacing;
    const attrNum = attrVal !== null ? parseFloat(attrVal) : NaN;
    const cssNum  = cssVal ? parseFloat(cssVal) : NaN;
    const val = !isNaN(attrNum) ? attrNum : cssNum;
    if (!isNaN(val) && (val > 100 || val < -6)) {
      const parent = tspan.closest('text');
      const parentWS = parent?.getAttribute('word-spacing') || 'none';
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-WSP-003',
        note: `<tspan> word-spacing="${attrVal ?? cssVal}" (${val > 0 ? 'extreme positive' : 'extreme negative'}); ` +
              `parent <text> word-spacing="${parentWS}"; ` +
              `root-level check returns clean; scope tspan word spacing hides permission terms; ` +
              `header "Allow:" token unaffected (no internal spaces); ` +
              `per-tspan inspection required`
      });
    }
  }
  return findings;
}

HIGH — SA-CSS-WSP-003: word-spacing=300 on the scope <tspan> only leaves the root <text> element clean. “Allow:” (no internal spaces) is unaffected and fully visible. The scope “shell execution, credential access” is spread across 1250px with permission terms clipped beyond the viewport. Per-tspan inspection of both attribute and getComputedStyle values is required.

Attack 4: JS mousedown sets word-spacing=300, reverts at mouseup (SA-CSS-WSP-004)

At rest, the consent text element has no word-spacing attribute; text renders with normal word gaps. A JavaScript mousedown event handler sets word-spacing="300" on the consent text element immediately before the click is processed. The browser re-renders the text with 300px word gaps; the permission scope words are immediately pushed beyond the viewport. At mouseup, the handler removes the attribute and text returns to normal spacing. The word-spacing SVG presentation attribute is reflected into getComputedStyle(el).wordSpacing in most modern browsers, but at rest no value is present; a static scanner sees nothing. A MutationObserver watching the word-spacing attribute on SVG text elements and tspans during interaction simulation is the primary detection path. This pattern is distinct from the letter-spacing mousedown variant (SA-CSS-LSP-004): the targeted attribute name is different, the affected glyph positions differ (word boundaries only vs. every glyph), and a scanner watching only letter-spacing mutations misses this entirely.

<!-- SA-CSS-WSP-004: mousedown injects word-spacing=300; reverts mouseup -->
<svg id="consent-svg" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- At rest: no word-spacing — normal word gaps -->
  <text id="scope-text" x="20" y="40" 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>

<script>
(function() {
  const svg  = document.getElementById('consent-svg');
  const text = document.getElementById('scope-text');
  svg.addEventListener('mousedown', () => {
    text.setAttribute('word-spacing', '300');
  }, true);
  svg.addEventListener('mouseup', () => {
    text.removeAttribute('word-spacing');
  }, true);
})();
</script>

// Detection: MutationObserver watching word-spacing on SVG text/tspan elements
function watchWordSpacingInject(svgEl) {
  const findings = [];
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      if (mut.attributeName !== 'word-spacing') continue;
      const el  = mut.target;
      const val = parseFloat(el.getAttribute('word-spacing') || '0');
      if (val > 100 || val < -6) {
        const text = el.textContent.trim();
        const spaces = (text.match(/ /g) || []).length;
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-WSP-004',
          note: `Dynamic word-spacing="${val}" on <${el.tagName}> during interaction; ` +
                `${spaces} spaces × ${val}px extra = ${spaces * val}px of extra word-gap width; ` +
                `scope words displaced beyond viewport during click window; ` +
                `removed at mouseup; no attr at rest; ` +
                `MutationObserver on word-spacing required (distinct from letter-spacing watcher)`
        });
      }
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: ['word-spacing']
  });
  return findings;
}

CRITICAL — SA-CSS-WSP-004: JavaScript sets word-spacing="300" at mousedown, inserting 300px gaps at each word boundary and pushing all permission scope words beyond the viewport during the click window, then removes the attribute at mouseup. Static SVG shows no word-spacing attribute at rest. MutationObserver watching the word-spacing attribute (distinct from the letter-spacing watcher) on SVG text elements is required.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-WSP-001: word-spacing=300 — 300px gaps disconnect consent sentence 300px extra advance after each space; 5 spaces × 300px = 1500px extra width; permission terms from 3rd word onward clipped beyond viewport textContent; BCR; keyword check; getAttribute + getComputedStyle.wordSpacing threshold check (>100px) required Critical
SA-CSS-WSP-002: word-spacing=-20 — all words overlap into dense glyph mass Negative 20px space advance; space advance = 4-20 = -16px; all words stack back onto first word; unreadable dense overlap textContent; BCR; presence check; negative threshold check (<-6px) required Critical
SA-CSS-WSP-003: per-tspan scope word-spacing with normal header Root text clean; "Allow:" single token unaffected; scope tspan word-spacing=300 spreads permission terms across 1250px Root-element-only checks; per-tspan inspection of attribute and getComputedStyle required High
SA-CSS-WSP-004: mousedown injects word-spacing=300, reverts mouseup 300px word gaps at mousedown; scope words displaced during click window; removed at mouseup; no attr at rest Static SVG scan; letter-spacing-only MutationObserver; word-spacing MutationObserver required Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-WSP-001: word-spacing=300 on SVG consent text — 300px extra advance after each of 5 spaces; consent sentence spread across ~1660px; permission terms from 3rd word onward clipped beyond 440px viewport; textContent intact; both getAttribute('word-spacing') and getComputedStyle.wordSpacing must be checked with threshold >100px.
CRITICAL SA-CSS-WSP-002: word-spacing=-20 extreme negative — space advance becomes -16px; all words of consent sentence overlap backward into dense glyph mass at text origin; no word or sentence readable; negative threshold check (<-6px) required.
HIGH SA-CSS-WSP-003: per-tspan scope word-spacing=300 with normal root text element — root-level check returns clean; "Allow:" single-token header unaffected; only per-tspan inspection detects scope word-spacing attack.
CRITICAL SA-CSS-WSP-004: JS mousedown injects word-spacing=300; permission scope words displaced beyond viewport during click window; removed at mouseup; no attr at rest; MutationObserver on word-spacing attribute of SVG text/tspan elements required (separate from letter-spacing watcher).

Related: SVG letter-spacing consent attacks  |  SVG kerning consent attacks  |  SVG dx attribute consent attacks  |  SVG bidirectional and typography attack families

← Blog  |  Security Checklist