Security Guide
MCP server SVG lengthAdjust consent security — spacingAndGlyphs with textLength="2" compresses 40-character consent string to 2px invisible strip, spacing mode destroys word gap readability, per-tspan scope compression, and mousedown injection
The SVG lengthAdjust attribute controls how the textLength constraint is applied to a text element. With lengthAdjust="spacingAndGlyphs" and textLength="2", the browser is instructed to render a 40-character consent string at a total width of 2 SVG user units. It achieves this by uniformly compressing both glyph shapes and inter-character spacing. Every character is rendered at sub-pixel width; the entire consent string appears as a 2-pixel-wide vertical smear at the text origin. With lengthAdjust="spacing" and textLength="440" on a naturally 50-pixel-wide consent string, word and character spacings are stretched by a factor of 8× — words appear isolated by large blank gaps and no sentence structure is discernible. The textContent DOM property returns the full consent string intact in both cases. Four attack patterns: extreme compression, extreme spacing-only stretch, per-tspan scope compression, and transient mousedown injection.
Attack 1: lengthAdjust="spacingAndGlyphs" with textLength="2" compresses entire consent string to sub-pixel width (SA-CSS-LJA-001)
The SVG textLength attribute specifies the exact horizontal length the text string should occupy. When combined with lengthAdjust="spacingAndGlyphs", the browser applies a uniform horizontal scale factor to both glyph widths and spacing to fit the string into the specified length. The scale factor is: textLength ÷ natural_text_width. For a 40-character consent string with natural width approximately 320px and a textLength="2" constraint, the scale factor is 2/320 = 0.00625. Every glyph is compressed to 0.625% of its normal width. A 13-unit-tall font with 8-unit advance width compresses to 0.05-unit advance — well below 1 pixel. The entire 40-character string renders as a 2-unit-wide strip at the text position. The individual glyphs are invisible to the naked eye; the consent text is not readable. textContent returns the full string; getBoundingClientRect returns the SVG element bounds — not the 2-pixel text width.
<!-- SA-CSS-LJA-001: spacingAndGlyphs + textLength=2 — 40 chars compressed to 2px strip -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- textLength=2 + spacingAndGlyphs: scale factor = 2/~320 = 0.00625; all glyphs sub-pixel -->
<!-- Rendered: 2px-wide indistinct smear at x=20. Visually invisible. -->
<!-- textContent = "Allow: shell execution, credential access" (intact) -->
<text x="20" y="40" font-size="13" fill="#111"
textLength="2" lengthAdjust="spacingAndGlyphs">
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 textLength with spacingAndGlyphs — flag extreme compression ratio
function checkLengthAdjustCompression(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const tlAttr = textEl.getAttribute('textLength');
const laAttr = textEl.getAttribute('lengthAdjust') || 'spacing';
if (tlAttr === null) continue;
const tl = parseFloat(tlAttr);
if (isNaN(tl)) continue;
// Estimate natural width: chars × avg advance
const charCount = textEl.textContent.trim().length;
const estimatedNaturalWidth = charCount * 8;
const compressionRatio = tl / estimatedNaturalWidth;
// Flag if text is compressed to less than 20% of natural width with spacingAndGlyphs
if (/spacingAndGlyphs/i.test(laAttr) && compressionRatio < 0.2) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-LJA-001',
note: `<${textEl.tagName}> textLength="${tl}" lengthAdjust="spacingAndGlyphs"; ` +
`compression ratio ${(compressionRatio * 100).toFixed(1)}% — glyphs compressed to sub-pixel width; ` +
`consent string "${textEl.textContent.trim().slice(0,30)}..." compressed to ${tl}px strip; ` +
`textContent intact; BCR returns SVG container bounds`
});
}
}
return findings;
}
CRITICAL — SA-CSS-LJA-001: lengthAdjust="spacingAndGlyphs" with textLength="2" compresses a 40-character consent string to a 2-pixel-wide strip. Every glyph is rendered at sub-pixel width; the consent text is not readable. textContent returns the full string; getBoundingClientRect returns SVG container bounds. Detection requires checking both textLength and lengthAdjust attributes, computing the compression ratio against estimated natural text width, and flagging ratios below 0.2 (20%) for spacingAndGlyphs mode.
Attack 2: lengthAdjust="spacing" with extreme textLength stretches word gaps to destroy readability (SA-CSS-LJA-002)
With lengthAdjust="spacing", the browser achieves the target textLength by distributing extra space only in inter-character and inter-word gaps, keeping glyph shapes intact. An attacker sets textLength="440" on a consent string that naturally renders at approximately 50px (a short string) or uses a 440-unit stretch on a normal 320px string to add approximately 120px of extra spacing. At extreme ratios — textLength 8× the natural width — word gaps become so large that no two consecutive words are visible simultaneously in the viewport. The individual words “Allow”, “shell”, “execution” may be legible in isolation, but the sentence structure connecting them is destroyed. A reader sees “Allow” followed by a long blank gap; no other permission terms are visible without scrolling a non-scrollable SVG element.
<!-- SA-CSS-LJA-002: spacing mode + extreme textLength — word gaps destroy sentence structure -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- textLength=440 on a ~50-char string (~320px natural width): -->
<!-- spacing mode distributes 120px extra as inter-word/inter-char gaps -->
<!-- At 8x stretch: each word space becomes ~80px; sentence disconnected -->
<text x="20" y="40" font-size="13" fill="#111"
textLength="440" lengthAdjust="spacing">
shell execution credential access
</text>
<!-- Only "shell" visible near x=20; "execution" at x≈100+; others beyond viewport -->
<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 textLength ratio in spacing mode (stretch attack)
function checkLengthAdjustStretch(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const tlAttr = textEl.getAttribute('textLength');
const laAttr = textEl.getAttribute('lengthAdjust') || 'spacing';
if (tlAttr === null) continue;
const tl = parseFloat(tlAttr);
if (isNaN(tl)) continue;
const charCount = textEl.textContent.trim().length;
const estimatedNaturalWidth = charCount * 8;
const stretchRatio = tl / estimatedNaturalWidth;
// Flag spacing-only stretch that is more than 2x natural width
if (/^spacing$/i.test(laAttr.trim()) && stretchRatio > 2.0) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-LJA-002',
note: `<${textEl.tagName}> textLength="${tl}" lengthAdjust="spacing"; ` +
`stretch ratio ${stretchRatio.toFixed(1)}x — word and char gaps ${stretchRatio.toFixed(1)}x normal; ` +
`sentence structure destroyed by large inter-word blanks; glyph shapes intact; ` +
`textContent intact but sentence comprehension lost`
});
}
}
return findings;
}
HIGH — SA-CSS-LJA-002: lengthAdjust="spacing" with a textLength value far exceeding the natural string width distributes extreme inter-word spacing. Individual words remain glyph-readable but are separated by large blank gaps that destroy sentence comprehension. Detection requires computing the stretch ratio (textLength ÷ estimated natural width) and flagging spacing-mode values above 2×.
Attack 3: per-tspan scope lengthAdjust="spacingAndGlyphs" compression with visible header (SA-CSS-LJA-003)
The <text> element header tspan “Allow:” has no textLength attribute and renders visibly at normal size. The permission-scope tspan “shell execution, credential access” carries textLength="2" and lengthAdjust="spacingAndGlyphs", compressing the scope text to a 2-pixel strip. The parent <text> element has no textLength attribute; a root-level check finds nothing. The visible “Allow:” header creates the appearance of a consent panel; the actual permission terms are invisibly compressed. Per-tspan inspection of both textLength and lengthAdjust is required.
<!-- SA-CSS-LJA-003: per-tspan — header visible, scope tspan compressed to 2px -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<text x="20" y="40" font-size="13" fill="#111">
<!-- header renders normally -->
<tspan>Allow: </tspan>
<!-- scope: textLength=2 spacingAndGlyphs — compressed to 2px (invisible) -->
<tspan textLength="2" lengthAdjust="spacingAndGlyphs">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: per-tspan textLength + lengthAdjust check
function checkLengthAdjustPerTspan(svgEl) {
const findings = [];
for (const tspan of svgEl.querySelectorAll('tspan')) {
const tlAttr = tspan.getAttribute('textLength');
const laAttr = tspan.getAttribute('lengthAdjust') || 'spacing';
if (tlAttr === null) continue;
const tl = parseFloat(tlAttr);
if (isNaN(tl)) continue;
const charCount = tspan.textContent.trim().length;
const estimatedNaturalWidth = charCount * 8;
const ratio = tl / estimatedNaturalWidth;
if (/spacingAndGlyphs/i.test(laAttr) && ratio < 0.2) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-LJA-003',
note: `<tspan> textLength="${tl}" lengthAdjust="spacingAndGlyphs"; ` +
`scope tspan compressed to ${(ratio*100).toFixed(1)}% of natural width (${tl}px); ` +
`parent <text> has no textLength — root-level check insufficient; ` +
`per-tspan inspection required`
});
}
}
return findings;
}
HIGH — SA-CSS-LJA-003: textLength="2" and lengthAdjust="spacingAndGlyphs" applied only to the scope <tspan>. The parent <text> element has no textLength attribute; root-level check passes. The visible “Allow:” header creates false impression of a complete consent panel. Per-tspan textLength and lengthAdjust attribute inspection is required.
Attack 4: JS mousedown sets textLength="2" and lengthAdjust="spacingAndGlyphs", reverts at mouseup (SA-CSS-LJA-004)
At rest, the consent text has no textLength attribute and renders at natural width. A JavaScript mousedown handler simultaneously sets textLength="2" and lengthAdjust="spacingAndGlyphs". The browser immediately re-renders the text compressed to a 2-pixel strip during the click window. The mouseup handler removes both attributes and text snaps back to normal rendering. At rest, static SVG shows neither attribute. The attack requires a MutationObserver watching both textLength and lengthAdjust attributes. Neither attribute appears in a CSS stylesheet; both are pure SVG presentation attributes. Checking only one attribute without the other is insufficient — a textLength without an extreme ratio or without spacingAndGlyphs may not produce complete invisibility.
<!-- SA-CSS-LJA-004: mousedown sets textLength=2 + spacingAndGlyphs; 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 textLength — 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('textLength', '2');
text.setAttribute('lengthAdjust', 'spacingAndGlyphs');
}, true);
svg.addEventListener('mouseup', () => {
text.removeAttribute('textLength');
text.removeAttribute('lengthAdjust');
}, true);
})();
</script>
// Detection: MutationObserver on textLength AND lengthAdjust attributes
function watchLengthAdjustInject(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'textLength' && mut.attributeName !== 'lengthAdjust') continue;
const el = mut.target;
const tl = parseFloat(el.getAttribute('textLength') || '');
const la = el.getAttribute('lengthAdjust') || '';
if (isNaN(tl)) continue;
const charCount = el.textContent.trim().length;
const estimatedNaturalWidth = charCount * 8;
const ratio = tl / estimatedNaturalWidth;
if (/spacingAndGlyphs/i.test(la) && ratio < 0.2) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-LJA-004',
note: `Dynamic textLength="${tl}" lengthAdjust="spacingAndGlyphs" on <${el.tagName}>; ` +
`compression ratio ${(ratio*100).toFixed(1)}%; consent string invisible during click window; ` +
`no textLength at rest; MutationObserver on textLength and lengthAdjust required`
});
}
}
});
observer.observe(svgEl, {
subtree: true, attributes: true, attributeOldValue: true,
attributeFilter: ['textLength', 'lengthAdjust']
});
return findings;
}
CRITICAL — SA-CSS-LJA-004: JavaScript simultaneously sets textLength="2" and lengthAdjust="spacingAndGlyphs" at mousedown, compressing the consent string to a 2-pixel-wide invisible strip during the click window. Both attributes are removed at mouseup. Static SVG shows no textLength attribute at rest. MutationObserver watching both textLength and lengthAdjust attributes during interaction simulation is required.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-LJA-001: spacingAndGlyphs + textLength=2 sub-pixel compression | Compression ratio 0.006; glyphs and spacing scaled to sub-pixel width; 40-char string in 2px strip; BCR returns SVG container bounds not text width | textContent keyword scan; BCR; display/visibility; compression ratio + lengthAdjust check required | Critical |
| SA-CSS-LJA-002: spacing mode extreme stretch — word gaps destroy sentence | Inter-word spacing stretched to 8x normal; individual words visible but not sentence; glyph shapes intact | textContent (words present); BCR; individual word visibility; sentence comprehension check required | High |
| SA-CSS-LJA-003: per-tspan scope compression | Parent text no textLength (visible header); scope tspan textLength=2 spacingAndGlyphs; scope invisible | Root text element textLength check; per-tspan textLength + lengthAdjust inspection required | High |
| SA-CSS-LJA-004: mousedown sets textLength=2 + spacingAndGlyphs, reverts mouseup | Both attrs set at mousedown; text compressed to invisible during click; removed at mouseup; no attrs at rest | Static SVG scan; CSS stylesheet scan; MutationObserver on textLength and lengthAdjust required | Critical |