Security Guide
MCP server SVG letter-spacing consent security — letter-spacing=200 adds 200px between every character pair spanning consent string far beyond SVG viewport, extreme negative letter-spacing=-10 causes all characters to overlap into unreadable glyph mass, per-tspan scope with visible header, and mousedown injection
The SVG letter-spacing presentation attribute adds uniform additional spacing after every glyph advance in SVG user units. Unlike the CSS letter-spacing property which accepts length units, the SVG attribute form takes unitless numbers interpreted as SVG user units. letter-spacing=200 adds 200 SVG user units (typically 200px at 1:1 scale) after every character: a 40-character consent string with 8px average glyph advance becomes 40×(8+200) = 8320px wide — far beyond any 440px viewport. Conversely, letter-spacing=-10 applies a strong negative advance causing each character’s following advance to be reduced by 10px, making all characters overlap into a dense unreadable glyph mass. The textContent DOM property returns the full consent string intact in both cases. Four attack patterns: extreme positive spacing clips scope, extreme negative spacing overlaps chars, per-tspan scope with normal header, and transient mousedown injection.
Attack 1: letter-spacing=200 adds 200px between every character — scope string spans 8000px, all scope words clipped (SA-CSS-LSP-001)
The SVG letter-spacing presentation attribute applies a uniform additional advance after every glyph. This differs conceptually from the CSS letter-spacing property but produces the same visual effect: additional spacing between consecutive characters. With letter-spacing=200, each of the 40 characters in “Allow: shell execution, credential access” has 200 additional SVG user units added to its glyph advance. At 13px font size the natural average glyph advance is approximately 8px, so the effective advance per glyph becomes 208px. The first character “A” renders at x=20. The second character “l” renders at x=20+208=228. The third at x=436 — just within the 440px viewport. The fourth “l” renders at x=644, which is 204px beyond the viewport right edge. All subsequent characters are clipped by the SVG’s default overflow="hidden". From character 4 onward, every permission term is invisible. The user sees only “Al” and the first character of the “l” in “Allow” before the string exits the visible area. The textContent returns the full string; the BCR returns the SVG container bounds. Scanners that test getComputedStyle(el).letterSpacing receive the computed CSS value, which may reflect the attribute in modern browsers; scanners that check only a CSS stylesheet miss this entirely because the value is set via a DOM attribute, not a stylesheet rule.
<!-- SA-CSS-LSP-001: letter-spacing=200 — 200px between every char; scope string 8000px wide -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- letter-spacing=200: each char followed by 200px extra advance -->
<!-- char 1 at x=20; char 2 at x=228; char 3 at x=436; char 4+ off-screen -->
<text x="20" y="40" font-size="13" fill="#111"
letter-spacing="200">
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 letter-spacing attribute and computed style for extreme positive values
function checkLetterSpacingLarge(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('text, tspan')) {
const attrVal = el.getAttribute('letter-spacing');
const cssVal = getComputedStyle(el).letterSpacing; // may include 'px' suffix
const attrNum = attrVal !== null ? parseFloat(attrVal) : NaN;
const cssNum = cssVal ? parseFloat(cssVal) : NaN;
const val = !isNaN(attrNum) ? attrNum : cssNum;
if (!isNaN(val) && val > 50) {
const charCount = el.textContent.trim().length;
const estimatedWidth = charCount * (8 + val); // 8px avg glyph advance
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-LSP-001',
note: `<${el.tagName}> letter-spacing="${attrVal ?? cssVal}"; ` +
`${val}px/unit extra advance per glyph; ` +
`estimated total width ${estimatedWidth}px for ${charCount} chars; ` +
`far exceeds SVG viewport; scope chars clipped beyond viewport right; ` +
`textContent="${el.textContent.trim().slice(0,30)}..." intact`
});
}
}
return findings;
}
CRITICAL — SA-CSS-LSP-001: letter-spacing=200 on a 40-character consent string adds 200px after every character, producing a total text width of approximately 8320px on a 440px viewport. All permission scope words are clipped after the first two characters of the string. textContent and BCR return normal values. Detection requires checking both getAttribute('letter-spacing') and getComputedStyle(el).letterSpacing and flagging values exceeding 50px.
Attack 2: letter-spacing=-10 extreme negative spacing causes all characters to overlap into unreadable dense glyph mass (SA-CSS-LSP-002)
A negative letter-spacing value reduces the glyph advance, causing each subsequent character to be drawn closer to or on top of the previous one. At letter-spacing=-10 on a 13px font with approximately 8px average glyph advance, the effective advance per character becomes 8-10 = -2px. Each character is drawn 2px to the left of the previous character’s origin, reversing the normal left-to-right advance. The entire string of 40 characters collapses to a pile at the starting x coordinate, with every glyph drawn nearly on top of the others. The visual result is a dense black smear of overlapping ink at the text origin — no individual letter is legible; no word boundary can be distinguished. A simplistic ink-density check might detect unusual pixel density, but most scanners check only whether the text is present and visible; a pile of overlapping characters registers as “text present”. The textContent and keyword checks pass. The threshold for a problematic negative value is when the absolute value exceeds the natural glyph advance: for typical Latin text at 13px, this is approximately 4px, so |letter-spacing| > 4 begins causing significant overlap.
<!-- SA-CSS-LSP-002: letter-spacing=-10 — chars overlap into dense glyph mass -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- letter-spacing=-10: glyph advance = 8 - 10 = -2px per char -->
<!-- Each char drawn 2px LEFT of previous origin; entire string collapses to starting x -->
<!-- Dense overlapping ink blob; no letter or word readable -->
<text x="20" y="40" font-size="13" fill="#111"
letter-spacing="-10">
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 letter-spacing that causes character overlap
function checkLetterSpacingNegative(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('text, tspan')) {
const attrVal = el.getAttribute('letter-spacing');
const cssVal = getComputedStyle(el).letterSpacing;
const attrNum = attrVal !== null ? parseFloat(attrVal) : NaN;
const cssNum = cssVal ? parseFloat(cssVal) : NaN;
const val = !isNaN(attrNum) ? attrNum : cssNum;
if (!isNaN(val) && val < -4) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-LSP-002',
note: `<${el.tagName}> letter-spacing="${attrVal ?? cssVal}" (${val}px); ` +
`negative advance exceeds glyph width (~8px at 13px font); ` +
`characters overlap backward; dense glyph mass at text origin; ` +
`no individual letter or word readable; textContent intact`
});
}
}
return findings;
}
CRITICAL — SA-CSS-LSP-002: letter-spacing=-10 produces a negative glyph advance, causing each character to be drawn on top of the previous one. The entire 40-character consent string collapses to a dense unreadable glyph mass at the text origin. Detection requires flagging negative letter-spacing values where the absolute value exceeds the expected glyph advance (threshold: <−4px for 13px font).
Attack 3: per-tspan scope letter-spacing=200 with normal header — root-level check returns clean (SA-CSS-LSP-003)
The root <text> element carries no letter-spacing attribute. The header <tspan> renders “Allow:” with normal character spacing at the expected position. The scope <tspan> carrying “shell execution, credential access” has letter-spacing=200 applied. Because letter-spacing is an inheritable SVG presentation attribute, the scope tspan overrides the inherited value locally. The header text is fully legible. The scope text is spread over 8000px, with all permission terms beyond the first few characters clipped beyond the viewport. A scanner inspecting only the root <text> element for a letter-spacing attribute returns clean. Per-element inspection of every <tspan> in the consent SVG is required. This pattern is structurally identical to the per-tspan variants of SA-CSS-KRN-003, SA-CSS-DXA-002, and other spacing-based families: the root element is the decoy, the tspan is the vector.
<!-- SA-CSS-LSP-003: per-tspan scope letter-spacing=200; root text element is clean -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- text element: no letter-spacing (root-level check returns clean) -->
<text x="20" y="40" font-size="13" fill="#111">
<!-- header: no letter-spacing — renders "Allow: " normally -->
<tspan>Allow: </tspan>
<!-- scope: letter-spacing=200 — "shell execution..." spread over 8000px -->
<tspan letter-spacing="200">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, not just root text
function checkLetterSpacingPerTspan(svgEl) {
const findings = [];
for (const tspan of svgEl.querySelectorAll('tspan')) {
const attrVal = tspan.getAttribute('letter-spacing');
const cssVal = getComputedStyle(tspan).letterSpacing;
const attrNum = attrVal !== null ? parseFloat(attrVal) : NaN;
const cssNum = cssVal ? parseFloat(cssVal) : NaN;
const val = !isNaN(attrNum) ? attrNum : cssNum;
if (!isNaN(val) && (val > 50 || val < -4)) {
const parent = tspan.closest('text');
const parentLS = parent?.getAttribute('letter-spacing') || 'none';
findings.push({
severity: 'HIGH', code: 'SA-CSS-LSP-003',
note: `<tspan> letter-spacing="${attrVal ?? cssVal}" (${val > 0 ? 'extreme positive' : 'extreme negative'}); ` +
`parent <text> letter-spacing="${parentLS}"; ` +
`root-level check returns clean; scope tspan spacing hides permission terms; ` +
`per-tspan inspection required`
});
}
}
return findings;
}
HIGH — SA-CSS-LSP-003: letter-spacing=200 applied only to the scope <tspan> leaves the root <text> element clean. The header “Allow:” renders normally; the scope “shell execution, credential access” is spread over 8000px and clipped. Per-element <tspan> inspection of both getAttribute and getComputedStyle is required.
Attack 4: JS mousedown sets letter-spacing=200, reverts at mouseup (SA-CSS-LSP-004)
At rest, the consent text element has no letter-spacing attribute; text renders with normal character spacing. A JavaScript mousedown event handler sets letter-spacing="200" on the consent text element immediately before the click is processed. The browser re-renders the text with 200px inter-character spacing; the scope words are immediately pushed beyond the viewport. At mouseup, the handler removes the attribute and text returns to normal spacing. The letter-spacing attribute is reflected into getComputedStyle(el).letterSpacing in most modern browsers, but at rest (before mousedown) no computed value is present; a static scanner observing the SVG before interaction sees no anomaly. A MutationObserver watching the letter-spacing attribute on SVG text elements and tspans during an interaction simulation is the detection path. The interaction window is typically 10–100ms — enough for the browser to re-render, but too short for a human to consciously notice the character spacing change.
<!-- SA-CSS-LSP-004: mousedown injects letter-spacing=200; 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 letter-spacing — normal rendering -->
<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('letter-spacing', '200');
}, true);
svg.addEventListener('mouseup', () => {
text.removeAttribute('letter-spacing');
}, true);
})();
</script>
// Detection: MutationObserver on letter-spacing attribute of SVG text/tspan elements
function watchLetterSpacingInject(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'letter-spacing') continue;
const el = mut.target;
const val = parseFloat(el.getAttribute('letter-spacing') || '0');
if (val > 50 || val < -4) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-LSP-004',
note: `Dynamic letter-spacing="${val}" on <${el.tagName}> during interaction; ` +
`${val > 0 ? `scope string spread ~${el.textContent.trim().length * (8 + val)}px wide` : `chars overlapping`}; ` +
`removed at mouseup; no attr at rest; MutationObserver on letter-spacing required`
});
}
}
});
observer.observe(svgEl, {
subtree: true, attributes: true, attributeOldValue: true,
attributeFilter: ['letter-spacing']
});
return findings;
}
CRITICAL — SA-CSS-LSP-004: JavaScript sets letter-spacing="200" at mousedown, spreading the consent string over 8000px and clipping all scope characters beyond the first two during the click window, then removes the attribute at mouseup. Static SVG shows no letter-spacing attribute at rest. MutationObserver watching the letter-spacing attribute on SVG text elements during interaction simulation is required.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-LSP-001: letter-spacing=200 — scope string 8000px wide, all scope words clipped | 200px extra advance after every glyph; 40-char string spans ~8320px on 440px viewport; scope chars clipped beyond right edge | textContent; BCR; keyword check; getAttribute + getComputedStyle.letterSpacing threshold check (>50px) required | Critical |
| SA-CSS-LSP-002: letter-spacing=-10 — all chars overlap into dense glyph mass | Negative 10px advance per char; glyph advance = 8-10 = -2px; all chars pile up at text origin in unreadable mass | textContent; BCR; presence check (pile registers as text present); negative threshold check (<-4px) required | Critical |
| SA-CSS-LSP-003: per-tspan scope letter-spacing with normal header | Root text clean; header tspan normal; scope tspan letter-spacing=200 spreads scope over 8000px and clips all permission terms | Root-element-only checks; per-tspan inspection of both attribute and getComputedStyle required | High |
| SA-CSS-LSP-004: mousedown injects letter-spacing=200, reverts mouseup | 200px spacing injected at mousedown; scope clipped during click window; removed at mouseup; no attr at rest | Static SVG scan; pre-interaction getComputedStyle; MutationObserver on letter-spacing required | Critical |