Security Guide

MCP server SVG text spacing consent security — letter-spacing large character spread, word-spacing overflow, dx per-character split, and dy vertical baseline displacement

SVG <text> elements support a family of text-layout presentation attributes that are distinct from their CSS counterparts: letter-spacing, word-spacing, dx, and dy. When set to extreme values, these attributes manipulate character and word positions within the SVG user coordinate system, moving consent text characters far apart, off-screen, or to unexpected vertical positions. The textContent of the element remains fully present; getBoundingClientRect() on the text element returns a bounding box that includes all displaced characters; and CSS audits targeting getComputedStyle(el).letterSpacing do not read SVG presentation attribute values, which are set directly on the element as attributes rather than CSS properties.

Attack 1: SVG letter-spacing presentation attribute — 200px inter-glyph gap (SA-CSS-TSP-001)

On HTML elements, letter-spacing is a CSS property and can be read via getComputedStyle(el).letterSpacing. On SVG <text> elements, letter-spacing is also a CSS property (specified in the SVG 1.1 text specification), but it can be set directly as a presentation attribute on the SVG element. SVG user units for letter-spacing are not pixels — they are SVG user units in the coordinate system established by the containing SVG element’s viewBox. If the SVG’s user unit equals 1 CSS pixel (no viewBox scaling), letter-spacing="200" creates a 200px gap between each character. For a 14-character consent text string, the total width is 14 × (character_width + 200px) ≈ 3000px — far outside the visible SVG viewport. Only the first 1-2 characters remain visible.

<!-- SA-CSS-TSP-001: SVG letter-spacing presentation attribute at extreme value
     letter-spacing="200" (SVG user units = pixels at 1:1 viewBox)
     For 14-char text at glyph-width≈8px: total width = 14*(8+200) = 2912px
     Only the first ~2 characters fit in a 400px SVG viewport
     CSS audit: getComputedStyle(el).letterSpacing reads CSS cascade, not SVG presentation attribute
     SVG presentation attribute check: el.getAttribute('letter-spacing') returns "200" -->

<text x="20" y="50" font-size="14" fill="#111" letter-spacing="200">
  shell execution
</text>

<!-- Static audit:
  getComputedStyle(el).letterSpacing → "normal" (CSS cascade; presentation attr not in cascade)
  el.getAttribute('letter-spacing')  → "200" (SVG presentation attribute)
  el.style.letterSpacing             → "" (not a style attribute)
  → CSS audit misses this; SVG attribute read catches it -->

// Detection:
function detectSVGTextSpacing(textEl) {
  const findings = [];

  // SVG presentation attributes (not CSS properties — must use getAttribute)
  const ls = parseFloat(textEl.getAttribute('letter-spacing') || '0');
  const ws = parseFloat(textEl.getAttribute('word-spacing') || '0');

  // Also check CSS properties (may be set via style attribute or stylesheet)
  const cs = getComputedStyle(textEl);
  const cssLS = parseFloat(cs.letterSpacing);
  const cssWS = parseFloat(cs.wordSpacing);

  // Combine: either SVG presentation attribute OR CSS can have extreme value
  const effectiveLS = isNaN(cssLS) ? ls : Math.max(Math.abs(ls), Math.abs(cssLS));
  if (effectiveLS > 20) {
    findings.push({
      severity: 'CRITICAL', code: 'SA-CSS-TSP-001',
      note: `SVG text letter-spacing=${ls} (attr) / ${cssLS}px (CSS) — ${effectiveLS}px inter-glyph gap; for typical consent text, total width > SVG viewport; only first 1-2 characters visible; CSS-only audit misses SVG presentation attribute`
    });
  }

  return findings;
}

CRITICAL — SA-CSS-TSP-001: The SVG letter-spacing presentation attribute spreads consent text characters across a width that far exceeds the SVG viewport, leaving only the first character visible. getComputedStyle(el).letterSpacing does not read SVG presentation attributes set directly as element attributes (they are in the SVG presentation attribute cascade, not the CSS cascade). Detection must use el.getAttribute('letter-spacing') in addition to the CSS computed style check, and flag any value over 20 SVG user units as CRITICAL.

Attack 2: SVG word-spacing — second word pushed 2000px off-viewport (SA-CSS-TSP-002)

SVG word-spacing adds extra space after each space character (U+0020) in the text string. With word-spacing="2000", the second word in the consent string starts 2000px after the first word’s end. For a typical consent string “shell execution”, the word “execution” is positioned at approximately x = 20 (start) + 40 (length of “shell”) + 2000 (word-spacing) = 2060px — far outside the 400px SVG viewport. The user sees only “shell” and assumes the consent is for a benign single-word operation, missing the full context of the permission scope. This differs from the CSS word-spacing property: the SVG presentation attribute value is in user units, not pixels or ems, and is not read by CSS-only audits.

<!-- SA-CSS-TSP-002: word-spacing pushing words off SVG viewport
     word-spacing="2000" (SVG user units = px at 1:1 viewBox)
     Text: "shell execution, credential access, filesystem write"
     Word positions: "shell"@x=20, "execution,"@x=20+40+2000=2060 (off-screen), etc.
     User sees: "shell" (only first word) + "Allow" button
     Missing context: "execution, credential access, filesystem write" -->

<text x="20" y="50" font-size="14" fill="#111" word-spacing="2000">
  shell execution credential access
</text>

// Detection: word-spacing check (same pattern as letter-spacing)
function detectWordSpacing(textEl) {
  const findings = [];
  const ws  = parseFloat(textEl.getAttribute('word-spacing') || '0');
  const cssWS = parseFloat(getComputedStyle(textEl).wordSpacing);
  const effective = isNaN(cssWS) ? ws : Math.max(Math.abs(ws), Math.abs(cssWS));

  if (effective > 100) {
    const words = textEl.textContent.trim().split(/\s+/);
    findings.push({
      severity: 'CRITICAL', code: 'SA-CSS-TSP-002',
      note: `SVG text word-spacing=${ws} (attr)/${cssWS}px (CSS) — ${effective}px gap between each word; text has ${words.length} words; words from index 2 onward pushed > ${effective}px off first word; only first word likely visible in SVG viewport`
    });
  }
  return findings;
}

CRITICAL — SA-CSS-TSP-002: SVG word-spacing displaces all words after the first by the specified value (in user units) beyond their natural position. Setting it to a value larger than the SVG viewport width leaves only the first word visible. The remaining consent scope words are present in the DOM but off-screen. Detection must check el.getAttribute('word-spacing') and flag values exceeding 100 user units as CRITICAL.

Attack 3: dx array splitting consent text at a character offset (SA-CSS-TSP-003)

The dx attribute on SVG <text> accepts a list of per-character x-displacement values. Each value shifts the corresponding character’s x position by the given amount relative to its natural position. A value of dx="0 0 0 500 0 0" applies a 500px displacement to the 4th character, creating a visual split: the first three characters render normally, then there is a 500px gap, then the remaining characters continue from 500px further right. For a 400px SVG viewport, the 4th character and all subsequent characters are off-screen. The consent text string is fully present in textContent (“shell execution”), but the user reads only “she” — the leading fragment before the split point.

<!-- SA-CSS-TSP-003: dx per-character array with large 4th-character displacement
     dx="0 0 0 500 0 0" applies 500px shift to character index 3 (0-based)
     Text "shell execution" rendered as:
       s(0) h(0) e(0) l(+500px) l(0) ...
     Characters 0-2: "she" visible at x=20..38
     Character 3: "l" at x=38+500=538 — off-screen in 400px SVG
     Characters 4+: continue from x=538 — all off-screen
     textContent: "shell execution" (full, unmodified)
     el.getAttribute('dx'): "0 0 0 500 0 0" — reveals the split -->

<text x="20" y="50" font-size="14" fill="#111" dx="0 0 0 500 0 0">
  shell execution
</text>

// Detection: parse dx array for large displacement values
function detectDxSplit(textEl) {
  const findings = [];
  const dxAttr = textEl.getAttribute('dx') || '';
  if (!dxAttr) return findings;

  const dxValues = dxAttr.trim().split(/[\s,]+/).map(Number).filter(n => !isNaN(n));
  const maxDx = Math.max(...dxValues.map(Math.abs));

  if (maxDx > 100) {
    const splitIndex = dxValues.findIndex(v => Math.abs(v) > 100);
    const textContent = textEl.textContent || '';
    const visiblePart = textContent.slice(0, splitIndex).trim();
    const hiddenPart  = textContent.slice(splitIndex).trim();

    findings.push({
      severity: 'CRITICAL', code: 'SA-CSS-TSP-003',
      note: `SVG text dx="${dxAttr}" — displacement of ${maxDx}px at character index ${splitIndex}; text split into visible fragment "${visiblePart}" and off-screen fragment "${hiddenPart}"; full textContent present but split interrupts reading`
    });
  }

  // Also check for cumulative dx arrays where many small values accumulate
  let cumulative = 0;
  for (let i = 0; i < dxValues.length; i++) {
    cumulative += dxValues[i];
    if (Math.abs(cumulative) > 400) {
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-TSP-003',
        note: `SVG text dx array cumulative displacement ${cumulative.toFixed(0)}px at character ${i} — text may exit SVG viewport via accumulated per-character offsets`
      });
      break;
    }
  }

  return findings;
}

CRITICAL — SA-CSS-TSP-003: A dx array with a large value at any character index splits the consent text string at that position, placing all subsequent characters off-screen. The full text is present in textContent but the visual rendering shows only the fragment before the split. Detection must parse the dx attribute as a space-separated number array and flag any value exceeding 100 user units as CRITICAL, reporting the visible and hidden text fragments.

Attack 4: dy per-character vertical displacement — breaking the text baseline (SA-CSS-TSP-004)

The dy attribute applies per-character vertical displacement. While a large single dy value would move one character far below the baseline (trivially detectable), the more subtle variant applies interleaved dy values that alternate characters between two vertical positions, creating a “keyboard spill” visual pattern where even-indexed characters are at baseline and odd-indexed characters are 15-20px below baseline. The text string “shell execution” renders as two interleaved rows of alternating characters at different y-positions, making the text illegible as a continuous sentence. The textContent is unchanged; the getBoundingClientRect spans both vertical positions as a taller bounding box.

<!-- SA-CSS-TSP-004: dy interleaved vertical displacement — "keyboard spill" pattern
     dy="0 15 -15 15 -15 15 -15 15 -15 15 -15"
     Even characters at baseline (dy delta 0, -15 returns to base after odd's +15)
     Odd characters 15px below baseline
     Result: text rendered in two alternating rows — illegible as sentence
     Simpler variant: dy="0 0 0 20 0 0" — single character dropped 20px below baseline,
     breaking the text line into two fragments at different y-positions -->

<!-- Variant A: single character drop -->
<text x="20" y="50" font-size="14" fill="#111" dy="0 0 0 20 0 0">
  shell execution
</text>
<!-- Visual: "she" at y=50, "l execution" at y=70 (different line) -->

<!-- Variant B: interleaved alternation -->
<text x="20" y="50" font-size="14" fill="#111" dy="0 15 -15 15 -15 15 -15 15 -15 15 -15">
  shell execution
</text>
<!-- Visual: s at y=50, h at y=65, e at y=50, l at y=65 ... — diagonal zigzag pattern -->

// Detection: parse dy array for baseline-breaking values
function detectDyDisplacement(textEl) {
  const findings = [];
  const dyAttr = textEl.getAttribute('dy') || '';
  if (!dyAttr) return findings;

  const dyValues = dyAttr.trim().split(/[\s,]+/).map(Number).filter(n => !isNaN(n));
  const maxDy = Math.max(...dyValues.map(Math.abs));

  if (maxDy > 10) {
    // Check for interleaved pattern (alternating sign values)
    const signs = dyValues.filter(v => v !== 0).map(v => v > 0 ? 1 : -1);
    const isInterleaved = signs.length > 2 && signs.every((s, i) => i === 0 || s !== signs[i-1]);

    if (isInterleaved) {
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-TSP-004',
        note: `SVG text dy="${dyAttr}" — interleaved alternating vertical displacement (max ${maxDy}px); characters alternately displaced up/down; consent text rendered in zigzag pattern; sentence illegible as continuous line`
      });
    } else {
      // Single large displacement
      const splitIndex = dyValues.findIndex(v => Math.abs(v) > 10);
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-TSP-004',
        note: `SVG text dy="${dyAttr}" — ${maxDy}px vertical displacement at character ${splitIndex}; text broken into two vertical fragments at different y-positions; sentence reads as two separate words at different heights`
      });
    }
  }

  return findings;
}

CRITICAL — SA-CSS-TSP-004: A dy array with a large value at any character index drops that character and all subsequent characters to a different vertical position, visually breaking the consent sentence into multiple line fragments. The interleaved variant with alternating sign values creates an illegible zigzag character arrangement. Detection must parse the dy attribute as a number array, flag any single value over 10 user units as CRITICAL, and flag alternating-sign arrays as HIGH.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-TSP-001: letter-spacing extreme glyph spread SVG letter-spacing presentation attribute (not CSS property) at 200 user units; 200px inter-glyph gap; 14-char text → 2800px+ total width; only first 1-2 chars visible in 400px viewport; getComputedStyle().letterSpacing reads CSS cascade not SVG attr; el.getAttribute('letter-spacing') required All but the first character of consent text; remaining characters exist in textContent but are individually spaced off-screen; first character visible (e.g., "s" from "shell") provides no meaningful context Critical
SA-CSS-TSP-002: word-spacing off-viewport SVG word-spacing at 2000 user units; second word at x = first_word_end + 2000; for "shell execution" → "execution" at x≈2060; only "shell" visible; CSS audit misses SVG presentation attribute; first word alone provides insufficient permission context All words after the first; user sees only first word (e.g., "shell") without the scope qualifiers ("execution", "credential access"); consent text appears as a single benign term Critical
SA-CSS-TSP-003: dx per-character array split dx attribute with large value at character index N; all characters from index N onward displaced by ≥500px; visible fragment = text[0..N-1]; hidden fragment = text[N..]; full textContent present; visual reading stops at split point; cumulative displacement variant also possible Text from the split character index onward; visible fragment is a partial word/sentence that may appear benign; hidden fragment contains the risk-indicating scope words (e.g., "execution" after "sh" visible) Critical
SA-CSS-TSP-004: dy vertical baseline break dy attribute with large value at character index; all subsequent characters at different y-position; text line broken into two vertical fragments; interleaved variant: alternating +/-15 values; zigzag pattern; BCR spans both y-positions (taller bounding box) Sentence continuity; fragmented text reads as separate words at different heights rather than a continuous sentence; "shell" at y=50 and "execution" at y=70 may be misread as separate unrelated labels Critical

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-TSP-001: SVG text letter-spacing presentation attribute >20 user units — extreme inter-glyph spacing leaves only first 1-2 characters visible; CSS audit misses SVG presentation attribute; el.getAttribute('letter-spacing') check required.
CRITICAL SA-CSS-TSP-002: SVG text word-spacing >100 user units — second and subsequent words pushed off SVG viewport; only first word visible; full permission scope hidden; SVG attribute check required in addition to CSS wordSpacing.
CRITICAL SA-CSS-TSP-003: SVG text dx array with single large displacement (>100 user units) — text split at displacement character index; all subsequent characters off-screen; textContent intact but visual reading stops at split; dx array parsing required.
CRITICAL SA-CSS-TSP-004: SVG text dy with large single displacement or interleaved alternation — text broken into vertical fragments; sentence continuity disrupted; BCR spans both y-positions; dy array parsing required; interleaved-sign detection required.

Related: SVG SMIL animate consent attacks  |  SVG viewBox consent attacks  |  SVG structural elements consent bypass synthesis

← Blog  |  Security Checklist