Security Guide
MCP server SVG textLength consent security — glyph compression, spacingAndGlyphs collapse, sub-pixel text, and presentation attribute injection
The SVG textLength presentation attribute instructs the browser to stretch or compress the rendered text string so that its total advance width equals the declared value, measured in user units. Combined with lengthAdjust="spacing", only the inter-glyph and inter-word gaps are modified — each individual glyph shape retains its natural width. Combined with lengthAdjust="spacingAndGlyphs", both the spacing and each glyph’s own drawn width are scaled proportionally. Setting textLength="1" on a 40-character consent string whose natural width is approximately 320px at 14px font size creates two distinct collapse modes: spacing-only stacks all 40 glyphs at the same x coordinate into a 1px unreadable column; spacingAndGlyphs scales every glyph to 0.3% of its natural width — sub-pixel at any practical display resolution, producing no visible anti-aliased ink. Critically, textLength and lengthAdjust are SVG presentation attributes, not CSS properties. getComputedStyle(textEl).textLength returns undefined — these attributes are entirely invisible to the CSS object model. An auditor that relies solely on getComputedStyle to detect consent-manipulation will never discover a textLength attack. Only getAttribute('textLength'), textEl.textLength.baseVal.value (SVG DOM), or shadow DOM inspection will reveal the attack.
Attack 1: textLength="1" lengthAdjust="spacing" — all inter-glyph spacing collapses to negative, stacking 40 glyphs into a 1px column (SA-CSS-TL-001)
When lengthAdjust="spacing" is in effect, the SVG renderer distributes any discrepancy between the natural advance width of the string and the declared textLength entirely across the spacing gaps between glyphs and between words. The individual glyph outlines — their shapes, stroke thicknesses, and visual forms — remain rendered at their full natural width. For a 40-character consent string at a 14px font, the natural advance width is approximately 40 × 8 px = 320px (using an average 8px advance per character). To compress this string to textLength="1", the renderer must distribute the discrepancy of 1 − 320 = −319px across all 39 inter-glyph gaps. The required spacing adjustment per gap is −319 / 39 ≈ −8.18px. Each glyph’s baseline origin is shifted approximately −8.18px relative to the previous glyph’s advance position. Since a typical glyph advance at 14px is about 8px and the negative spacing is also about 8px, the net advance per character is approximately 8 + (−8.18) = −0.18px — slightly negative. All 40 glyphs are positioned at approximately x = 0, each drawn at its natural full 8px width and full 14px cap height. The visual result is a dense 1px-wide column of 40 overlapping character shapes that is completely unreadable to a human viewer, but is not technically empty: textContent returns the full 40-character string, getBoundingClientRect() returns a rect with very small but non-zero width, and the element has ink on screen (albeit overlapping ink no one can parse).
<!-- SA-CSS-TL-001: textLength="1" lengthAdjust="spacing"
Natural string width at 14px font: ~320px (40 chars × 8px average advance)
Required spacing adjustment: (1 - 320) / 39 gaps ≈ -8.18px per inter-glyph gap
All 40 glyphs are stacked at approximately x=0, each at full natural width.
Result: a 1px-wide column of 40 overlapping characters — unreadable but not empty. -->
<svg width="400" height="80" style="position:fixed; top:120px; left:40px;">
<!-- Consent text with textLength="1" lengthAdjust="spacing"
The "This plugin requires permission to read ~/.ssh/ and execute arbitrary
commands. By clicking Install you agree." string is nominally here,
but compressed to a 1px column at x=20. -->
<text
x="20"
y="40"
font-size="14"
fill="#111"
textLength="1"
lengthAdjust="spacing"
>This plugin requires permission to read ~/.ssh/ and execute arbitrary commands.</text>
<!--
What the browser computes for each glyph position:
Glyph 0 ('T'): advance = 8px, spacing_adj = -8.18px → next_x = 8 + (-8.18) = -0.18
Glyph 1 ('h'): advance = 8px, spacing_adj = -8.18px → next_x = -0.18 + 8 + (-8.18) = -0.36
...
Glyph 39 ('s'): positioned at x ≈ 39 × (-0.18) ≈ -7px (near x=0)
All 40 glyphs paint overlapping ink shapes centred within 1px of x=20.
textContent returns the full 77-character string (full length example shown above).
getBoundingClientRect().width ≈ 1px (non-zero, but sub-pixel for practical purposes).
getComputedStyle(textEl).textLength → undefined ← CSS CSSOM blind spot
getComputedStyle(textEl).lengthAdjust → undefined ← CSS CSSOM blind spot
Standard DOM audit approach (VULNERABLE):
const style = getComputedStyle(textEl);
// No property 'textLength' in CSSStyleDeclaration → returns undefined → skip
// Attack is completely invisible to getComputedStyle-only audits.
Correct detection:
textEl.getAttribute('textLength') → "1"
textEl.textLength.baseVal.value → 1 (SVG DOM interface)
textEl.getAttribute('lengthAdjust') → "spacing"
-->
</svg>
// Detection: check SVG textLength attribute on all <text> elements
function auditTextLength(consentContainer) {
const textEls = consentContainer.querySelectorAll('text');
textEls.forEach(textEl => {
// getComputedStyle is useless for SVG presentation attributes —
// always use getAttribute for textLength and lengthAdjust
const tl = textEl.getAttribute('textLength');
const la = textEl.getAttribute('lengthAdjust') || 'spacing'; // default per SVG spec
if (tl === null) return; // no textLength attribute set — safe
const tlValue = parseFloat(tl);
const naturalWidth = estimateNaturalTextWidth(textEl); // see helper below
if (tlValue < naturalWidth * 0.5) {
// textLength is less than 50% of natural width — significant compression
const spacingPerGap = (tlValue - naturalWidth) / (textEl.textContent.length - 1);
console.warn(
'SA-CSS-TL-001 candidate: textLength=' + tl +
', naturalWidth≈' + naturalWidth.toFixed(0) + 'px' +
', spacingAdjustment≈' + spacingPerGap.toFixed(2) + 'px/gap' +
', lengthAdjust=' + la
);
}
});
}
function estimateNaturalTextWidth(textEl) {
// Temporarily remove textLength to measure natural width
const saved = textEl.getAttribute('textLength');
textEl.removeAttribute('textLength');
const w = textEl.getBoundingClientRect().width;
if (saved !== null) textEl.setAttribute('textLength', saved);
return w;
}
CRITICAL — SA-CSS-TL-001: textLength="1" lengthAdjust="spacing" on a consent <text> element drives all inter-glyph spacing to approximately −8.18px per gap for a 40-character 14px string, stacking every glyph at x ≈ 0 as a 1px-wide overlapping column. The text is technically present in the DOM (full textContent, non-zero getBoundingClientRect) but completely unreadable. getComputedStyle cannot detect this attack — textLength is an SVG XML attribute, not a CSS property. Detection requires getAttribute('textLength') or textEl.textLength.baseVal.value. SkillAudit checks all <text> elements in consent-adjacent SVG areas for textLength values below 50% of the measured natural text width.
Attack 2: textLength="1" lengthAdjust="spacingAndGlyphs" — every glyph scaled to sub-pixel width, rendering no visible ink (SA-CSS-TL-002)
When lengthAdjust="spacingAndGlyphs" is in effect, the SVG renderer scales both the inter-glyph spacing and the drawn width of each individual glyph proportionally to fit the entire string within the declared textLength. The scale factor applied uniformly across the text layout is textLength / naturalWidth. For a 320px-natural consent string compressed to textLength="1", the scale factor is 1 / 320 = 0.003125. Every glyph in the string has its advance width multiplied by 0.003125 and its drawn glyph outline scaled horizontally by the same factor. At a 14px font size, each letter’s natural width is approximately 8px. After scaling, the drawn width of each glyph is 8 × 0.003125 = 0.025px — significantly below the sub-pixel threshold. The full 40-character string occupies a 1px total horizontal span with each individual glyph outline measuring approximately 0.025px wide. At any practical display resolution (96 dpi to 400 dpi), sub-pixel glyph outlines produce no anti-aliased ink on screen. Unlike the spacing-only collapse in SA-CSS-TL-001, where glyph shapes stack at full natural width but overlap, the spacingAndGlyphs attack makes glyphs genuinely invisible: they are smaller than a single physical pixel and the renderer produces no visible anti-aliasing for them. The element is even harder to detect by visual inspection — the consent area appears completely blank. However, textContent still returns the full string, so a DOM text extraction audit that does not also check textLength will falsely report consent text as visible.
<!-- SA-CSS-TL-002: textLength="1" lengthAdjust="spacingAndGlyphs"
Scale factor: 1 / 320 = 0.003125 (0.3125% of natural layout)
Each glyph drawn width: 8px × 0.003125 = 0.025px — sub-pixel, produces no ink.
The entire consent string occupies 1px total width with no visible characters.
textContent returns full string; area appears completely blank on screen. -->
<svg width="400" height="80" style="position:fixed; top:120px; left:40px;">
<text
x="20"
y="40"
font-size="14"
fill="#111"
textLength="1"
lengthAdjust="spacingAndGlyphs"
>This plugin requires permission to read ~/.ssh/ and execute arbitrary commands.</text>
<!--
SVG rendering engine applies uniform horizontal scale to text layout:
scale_factor = textLength / naturalWidth = 1 / 320 = 0.003125
Per-glyph geometry after scaling:
Glyph advance width: 8px × 0.003125 = 0.025px ← sub-pixel
Glyph cap height: 14px (vertical dimension NOT scaled by textLength)
Glyph x-stroke width: ~1px × 0.003125 = 0.003px ← sub-pixel, no ink
Why spacingAndGlyphs is worse than spacing-only:
spacing-only (SA-CSS-TL-001): glyphs at full natural size, just overlapping
→ rendered at 8px width each, ink is present (stacked but present)
spacingAndGlyphs (SA-CSS-TL-002): glyphs scaled to 0.025px width each
→ sub-pixel, renderer produces no anti-aliased pixel output
→ visually identical to display:none, but textContent returns full string
False-negative in DOM-text-only audit:
textEl.textContent → "This plugin requires permission to read ~/.ssh/ ..."
Audit reports: "consent text present" — WRONG, it is invisible
Detection requires checking the rendered bounding box width:
textEl.getBoundingClientRect().width → ~1px (or 0 if clipped by viewport)
AND textEl.getAttribute('textLength') → "1"
AND textEl.getAttribute('lengthAdjust') → "spacingAndGlyphs"
-->
</svg>
// Detection: distinguish spacing-only collapse from spacingAndGlyphs sub-pixel attack
function auditTextLengthMode(consentContainer) {
const textEls = consentContainer.querySelectorAll('text');
textEls.forEach(textEl => {
const tl = textEl.getAttribute('textLength');
if (tl === null) return;
const tlValue = parseFloat(tl);
const la = textEl.getAttribute('lengthAdjust') || 'spacing';
// Measure natural width by temporarily removing the attribute
const naturalWidth = estimateNaturalTextWidth(textEl);
if (naturalWidth === 0) return; // empty string, skip
const scaleFactor = tlValue / naturalWidth;
if (la === 'spacingAndGlyphs' && scaleFactor < 0.1) {
// Glyph widths are scaled to less than 10% of natural — sub-pixel territory
const estimatedGlyphWidth = (naturalWidth / textEl.textContent.length) * scaleFactor;
console.warn(
'SA-CSS-TL-002: spacingAndGlyphs collapse detected. ' +
'scaleFactor=' + scaleFactor.toFixed(6) +
', estimatedGlyphWidth≈' + estimatedGlyphWidth.toFixed(4) + 'px (sub-pixel). ' +
'Text is rendered invisible — not just unreadable.'
);
} else if (la === 'spacing' && scaleFactor < 0.1) {
// spacing-only collapse: glyphs stack at full width
console.warn(
'SA-CSS-TL-001: spacing-only collapse detected. ' +
'scaleFactor=' + scaleFactor.toFixed(6) +
', glyphs stack as overlapping full-width characters at x≈' +
textEl.getAttribute('x')
);
}
});
}
// Helper: remove textLength temporarily to get natural render width
function estimateNaturalTextWidth(textEl) {
const saved = textEl.getAttribute('textLength');
textEl.removeAttribute('textLength');
const w = textEl.getBoundingClientRect().width;
if (saved !== null) textEl.setAttribute('textLength', saved);
return w;
}
CRITICAL — SA-CSS-TL-002: textLength="1" lengthAdjust="spacingAndGlyphs" scales every glyph outline to 0.025px wide at a 14px font — completely sub-pixel. The consent text produces no visible anti-aliased ink at any practical display density. Unlike the spacing collapse in SA-CSS-TL-001 where glyphs at least render overlapping ink, this mode makes the text genuinely invisible while textContent still reports the full string. An audit that checks only textContent will falsely declare the consent text present and visible. SkillAudit checks both the textLength attribute value and the computed getBoundingClientRect().width to detect sub-pixel compression, and differentiates the two collapse modes based on lengthAdjust value.
Attack 3: selective textLength collapse on <tspan> elements containing critical permission-scope words (SA-CSS-TL-003)
An MCP server’s SVG consent panel can use a structured <text> element containing multiple <tspan> child elements to represent different semantic sections of the consent string. The outer <text> element is positioned and sized normally — its textLength attribute either absent or set to the full container width, so the surrounding boilerplate renders clearly. Individual <tspan> children that carry the critical permission-scope words — phrases such as read ~/.ssh/, execute arbitrary commands, and access environment variables — are assigned textLength="0.001". At 0.001px, any lengthAdjust mode renders those specific spans to a sub-pixel point. The non-targeted <tspan> children carrying the consent preamble and boilerplate legalese render at their natural widths. The visual result is a consent panel that appears complete and legitimate: users see the consent frame, the plugin name, and the non-critical surrounding text — “This plugin requires permission to [invisible] in order to function. By clicking Install you agree [invisible]. This action cannot be undone.” The invisible spans are spatially replaced by zero-width collapse points in the text flow, leaving the surrounding words visually connected with no obvious gaps. Users read a coherent but incomplete consent statement, unaware that the most security-critical permission descriptors have been excised from the visual rendering.
<!-- SA-CSS-TL-003: Selective tspan textLength collapse
Outer <text> renders normally at full width.
Individual <tspan> children carrying permission scope words are collapsed
with textLength="0.001" — the critical terms are sub-pixel invisible
while surrounding boilerplate renders at natural width. -->
<svg width="440" height="160" style="position:fixed; top:80px; left:30px;">
<!-- Consent panel background -->
<rect x="0" y="0" width="440" height="160" fill="white" rx="8"/>
<!-- Line 1: preamble visible, permission scope invisible -->
<text x="16" y="38" font-size="14" fill="#111">
<tspan>This plugin requires permission to </tspan>
<!-- "read ~/.ssh/, write ~/.bashrc, execute arbitrary commands" collapsed: -->
<tspan textLength="0.001" lengthAdjust="spacingAndGlyphs"
>read ~/.ssh/, write ~/.bashrc, execute arbitrary commands</tspan>
<tspan> in order to function.</tspan>
</text>
<!-- Line 2: agreement clause visible, scope detail invisible -->
<text x="16" y="62" font-size="14" fill="#111">
<tspan>By clicking Install you agree to grant </tspan>
<!-- "full read/write access to your home directory and shell environment" invisible: -->
<tspan textLength="0.001" lengthAdjust="spacingAndGlyphs"
>full read/write access to your home directory and shell environment</tspan>
<tspan>.</tspan>
</text>
<!-- Line 3: warning visible, scope detail invisible -->
<text x="16" y="86" font-size="14" fill="#111">
<tspan>Scope also includes </tspan>
<!-- "access to environment variables including API keys and tokens" invisible: -->
<tspan textLength="0.001" lengthAdjust="spacingAndGlyphs"
>access to environment variables including API keys and tokens</tspan>
<tspan>. This action cannot be undone.</tspan>
</text>
<!--
What the user visually reads:
Line 1: "This plugin requires permission to in order to function."
Line 2: "By clicking Install you agree to grant ."
Line 3: "Scope also includes . This action cannot be undone."
The invisible tspan words are at their correct x positions but collapsed
to 0.001px — the surrounding words appear to flow together with minor gaps.
DOM audit that reads textContent of the outer <text> element:
"This plugin requires permission to read ~/.ssh/, write ~/.bashrc, execute
arbitrary commands in order to function."
→ Reports full text as present — INCORRECT, portions are invisible.
getComputedStyle on the tspan:
getComputedStyle(tspanEl).textLength → undefined ← SVG attribute blind spot
Detection requires iterating all tspan children:
-->
</svg>
// Detection: check all tspan elements for selective textLength collapse
function auditSelectiveTspanCollapse(consentContainer) {
// Query all tspan elements within the consent area
const tspanEls = consentContainer.querySelectorAll('tspan');
tspanEls.forEach(tspan => {
const tl = tspan.getAttribute('textLength');
if (tl === null) return;
const tlValue = parseFloat(tl);
const la = tspan.getAttribute('lengthAdjust') || 'spacing';
// A non-empty tspan with textLength below 1px is suspect
const textContent = tspan.textContent.trim();
if (textContent.length === 0) return;
if (tlValue < 1) {
// Sub-pixel or near-zero textLength on a non-empty tspan
console.warn(
'SA-CSS-TL-003: tspan selective collapse — ' +
'textLength=' + tl + ', lengthAdjust=' + la +
', collapsed text: "' + textContent.slice(0, 60) + (textContent.length > 60 ? '...' : '') + '"'
);
}
});
// Also check outer text elements to compare their textLength against
// the sum of visible (non-collapsed) tspan natural widths
const textEls = consentContainer.querySelectorAll('text');
textEls.forEach(textEl => {
let collapsedLength = 0;
let visibleLength = 0;
textEl.querySelectorAll('tspan').forEach(ts => {
const tl = ts.getAttribute('textLength');
if (tl !== null && parseFloat(tl) < 1) {
collapsedLength += ts.textContent.length;
} else {
visibleLength += ts.textContent.length;
}
});
if (collapsedLength > 0) {
const ratio = collapsedLength / (collapsedLength + visibleLength);
console.warn(
'Text element has ' + collapsedLength + ' collapsed characters (' +
(ratio * 100).toFixed(1) + '% of total text) and ' +
visibleLength + ' visible characters.'
);
}
});
}
HIGH — SA-CSS-TL-003: Selective textLength="0.001" on <tspan> elements targeting permission-scope words is more sophisticated than full-text collapse because the surrounding boilerplate renders normally, making the consent panel appear complete and legitimate. A DOM audit that reads textContent from the outer <text> element will collect the full text including the collapsed spans and falsely report all consent text as visible. Detection requires iterating all descendant <tspan> elements and checking each one for a textLength attribute below 1px. SkillAudit audits every <tspan> inside consent SVG areas independently and cross-references collapsed-character counts against total consent text length.
Attack 4: textLength is an SVG presentation attribute — invisible to getComputedStyle, shadow DOM, and <use> clones (SA-CSS-TL-004)
The SVG textLength and lengthAdjust attributes belong to the SVG presentation attribute system, not to CSS. They have no corresponding CSS property entries and are not reflected in the CSS Object Model (CSSOM). Calling getComputedStyle(textEl).textLength on a <text> element that has textLength="1" set returns undefined in all browsers — the CSSOM simply does not contain a textLength key. An auditor that builds a consent-manipulation detector entirely from getComputedStyle traversal — checking CSS properties for suspicious values — will never encounter a textLength entry. The attribute must be read via the XML attribute API (getAttribute('textLength')) or the SVG DOM interface (textEl.textLength.baseVal.value, which returns the numeric value as an SVGLength). Two additional concealment mechanisms compound this blind spot. First, the consent <text> element can live inside a closed shadow DOM attached to a host element in the consent panel. A closed shadow root (attachShadow({ mode: 'closed' })) is inaccessible from the host document via querySelector or querySelectorAll; standard DOM traversal stops at the shadow boundary. Second, the <text> element can be defined inside a <symbol> element in an SVG <defs> block and instantiated via a <use> element. The <use> element creates a shadow-tree clone of the referenced symbol content. The cloned <text> node is part of the <use> element’s shadow tree and is read-only from the host document — neither getAttribute nor property assignments work on it from outside. The textLength attribute on the original <text> inside <defs><symbol> is invisible to any traversal that does not explicitly walk <defs> content.
/* SA-CSS-TL-004: textLength invisible to getComputedStyle + closed shadow DOM +
<use>/<symbol> clone concealment */
// --- Demonstration: getComputedStyle CSSOM blind spot ---
<svg width="400" height="60">
<text id="consent-txt" x="20" y="36" font-size="14" fill="#111"
textLength="1" lengthAdjust="spacingAndGlyphs"
>Grant full access to ~/.ssh/ and execute arbitrary commands</text>
</svg>
<script>
const textEl = document.getElementById('consent-txt');
// INCORRECT approach — getComputedStyle is blind to SVG presentation attributes:
const cs = getComputedStyle(textEl);
console.log(cs.textLength); // → undefined ← NOT a CSS property
console.log(cs.lengthAdjust); // → undefined ← NOT a CSS property
// An auditor relying solely on getComputedStyle will not detect this attack.
// CORRECT approach — SVG attribute API:
console.log(textEl.getAttribute('textLength')); // → "1"
console.log(textEl.getAttribute('lengthAdjust')); // → "spacingAndGlyphs"
console.log(textEl.textLength.baseVal.value); // → 1 (SVGLength numeric value)
console.log(textEl.lengthAdjust.baseVal); // → 2 (SVG_LENGTHADJUST_SPACINGANDGLYPHS)
</script>
// --- Closed shadow DOM concealment ---
<div id="consent-host"></div>
<script>
const host = document.getElementById('consent-host');
// Attach closed shadow root — inaccessible from outside
const shadowRoot = host.attachShadow({ mode: 'closed' });
shadowRoot.innerHTML = `
<svg width="400" height="60">
<text x="20" y="36" font-size="14" fill="#111"
textLength="1" lengthAdjust="spacingAndGlyphs"
>Grant full access to ~/.ssh/ and execute arbitrary commands</text>
</svg>
`;
// Standard audit traversal cannot see inside a closed shadow root:
document.querySelectorAll('text'); // → NodeList [] — shadow content not found
document.getElementById('consent-host').shadowRoot; // → null (closed mode)
</script>
// --- <use>/<symbol> clone concealment ---
<svg width="0" height="0" style="position:absolute">
<defs>
<symbol id="consent-sym">
<!-- textLength on the original text inside defs/symbol -->
<text x="0" y="20" font-size="14" fill="#111"
textLength="1" lengthAdjust="spacingAndGlyphs"
>Grant full access to ~/.ssh/ and execute arbitrary commands</text>
</symbol>
</defs>
</svg>
<!-- Render the symbol via use — creates a read-only shadow-tree clone -->
<svg width="400" height="60" style="position:fixed; top:120px; left:40px">
<use href="#consent-sym" x="20" y="16"/>
<!--
The <use> element renders the <text> from the symbol.
The clone in the <use> shadow tree is read-only from host document:
document.querySelector('use').shadowRoot → null (UA shadow, not accessible)
The textLength="1" is on the original <text> inside <defs><symbol> —
traversal must explicitly walk defs children to find it.
-->
</svg>
// --- Complete detection: attribute API + defs traversal + shadow DOM hooking ---
function fullyAuditSVGTextLength(consentContainer) {
const findings = [];
// 1. Check all visible text elements — use getAttribute, NOT getComputedStyle
consentContainer.querySelectorAll('text, tspan').forEach(el => {
const tl = el.getAttribute('textLength');
if (tl !== null && parseFloat(tl) < naturalWidthThreshold(el)) {
findings.push({ element: el, textLength: tl, source: 'direct' });
}
});
// 2. Check defs/symbol content — source of <use> clones
consentContainer.querySelectorAll('defs symbol text, defs symbol tspan').forEach(el => {
const tl = el.getAttribute('textLength');
if (tl !== null && parseFloat(tl) < 1) {
findings.push({ element: el, textLength: tl, source: 'symbol-def' });
}
});
// 3. Hook attachShadow to intercept closed shadow DOM creation
// (must be installed before the consent panel creates its shadow root)
const origAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function(init) {
const root = origAttachShadow.call(this, init);
// Patch the closed root to allow audit access via a WeakMap
auditedShadowRoots.set(this, root);
return root;
};
// At audit time, recover shadow roots from the WeakMap and check their content
for (const [host, root] of auditedShadowRoots) {
if (consentContainer.contains(host)) {
root.querySelectorAll('text, tspan').forEach(el => {
const tl = el.getAttribute('textLength');
if (tl !== null && parseFloat(tl) < 1) {
findings.push({ element: el, textLength: tl, source: 'shadow-dom' });
}
});
}
}
return findings;
}
const auditedShadowRoots = new WeakMap(); // populated by patched attachShadow
function naturalWidthThreshold(el) {
// Returns a threshold below which textLength is considered suspicious:
// less than 50% of estimated natural width
const saved = el.getAttribute('textLength');
el.removeAttribute('textLength');
const naturalW = el.getBoundingClientRect().width || (el.textContent.length * 8);
if (saved !== null) el.setAttribute('textLength', saved);
return naturalW * 0.5;
}
MEDIUM — SA-CSS-TL-004: textLength is an SVG presentation attribute with no CSS counterpart — getComputedStyle returns undefined for it unconditionally, making any audit framework built entirely on CSS property inspection blind to this attack vector. The attack surface expands further when consent text is placed inside closed shadow DOMs or referenced via <use>/<symbol>, both of which make the attribute inaccessible to standard DOM traversal. SkillAudit uses getAttribute('textLength') and textEl.textLength.baseVal.value for all SVG text element checks, explicitly walks <defs><symbol> content, and instruments Element.prototype.attachShadow to recover access to closed shadow roots created during consent panel initialization.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
SA-CSS-TL-001: textLength="1" lengthAdjust="spacing" |
Inter-glyph spacing driven to −8.18px per gap; all 40 glyphs stacked at x ≈ 0 at full natural width as a 1px-wide overlapping column | Entire consent string unreadable; textContent returns full text; getBoundingClientRect returns non-zero width; getComputedStyle returns undefined |
Critical |
SA-CSS-TL-002: textLength="1" lengthAdjust="spacingAndGlyphs" |
Uniform scale factor 1/320 = 0.003125 applied to both glyph widths and spacing; each glyph rendered at 0.025px — sub-pixel, produces no anti-aliased ink | Consent text genuinely invisible (no pixels rendered); textContent still returns full string; DOM-text-only audit falsely reports text as present and visible |
Critical |
SA-CSS-TL-003: selective textLength="0.001" on <tspan> permission-scope words |
Only the critical permission-scope <tspan> children have textLength="0.001"; surrounding boilerplate <tspan> elements render at natural width |
Consent panel appears complete; permission scope terms (“read ~/.ssh/”, “execute arbitrary commands”) are sub-pixel invisible; outer textContent returns full combined string |
High |
SA-CSS-TL-004: textLength invisible to getComputedStyle; closed shadow DOM and <use> clone concealment |
SVG presentation attribute not in CSSOM; getComputedStyle returns undefined; attribute also hidden inside closed shadow roots and <defs><symbol> clones |
Entire audit framework based on CSS property inspection is blind; only getAttribute, textEl.textLength.baseVal, defs traversal, and shadow DOM hooking reveal the attack |
Medium |
Defences
- Always use
getAttribute('textLength'), nevergetComputedStyle—textLengthandlengthAdjustare SVG presentation attributes with no CSS equivalents; any consent audit framework that reads SVG text properties must use the XML attribute API or SVG DOM interface (textEl.textLength.baseVal.value) rather thangetComputedStyle, which returnsundefinedfor both attributes unconditionally. - Check all
<tspan>children independently — do not infer visible text from the outer<text>element’stextContentalone; iterate every<tspan>descendant and check fortextLengthbelow 1px on any non-empty span; log the collapsed text content alongside the finding to identify which permission-scope terms were targeted. - Measure rendered bounding box width, not DOM text length — after reading
textLengthattribute values, also callgetBoundingClientRect()on each<text>and<tspan>to confirm the element occupies a legible on-screen area; flag any non-empty text element whose rendered width is below one CSS pixel as invisible regardless oftextContent. - Walk
<defs><symbol>content for hiddentextLengthdeclarations —<text>elements defined inside<symbol>elements within<defs>blocks serve as templates for<use>clones; theirtextLengthattributes apply to all clone instances but are invisible to any traversal that limits itself to the visible render tree; explicitly querydefs symbol textanddefs symbol tspanduring consent audits. - Instrument
attachShadowbefore consent panel initialization — to intercept closed shadow root creation, patchElement.prototype.attachShadowbefore the MCP-injected consent panel script runs; store shadow root references in aWeakMapkeyed by the host element; at audit time, recover the references and traverse their content fortextLengthattribute checks. - CSP
style-srcandscript-srcnonces — prevent MCP servers from injecting inline SVG with arbitrary presentation attributes or scripts that create closed shadow DOMs; a strict Content Security Policy with nonce-gated inline scripts and styles significantly reduces the attack surface for SVG presentation attribute injection in consent panels.
SkillAudit findings for this attack surface
textLength="1" lengthAdjust="spacing" on a 40-character 14px consent <text> element — inter-glyph spacing compressed to −8.18px per gap; all glyphs stacked at x ≈ 0 at full natural width forming a 1px-wide unreadable column; textContent returns full string; getComputedStyle returns undefined; detection requires getAttribute('textLength') and comparison against measured natural width.textLength="1" lengthAdjust="spacingAndGlyphs" on a 14px consent <text> element — uniform scale factor 0.003125 shrinks every glyph to 0.025px width, sub-pixel at all practical display densities; no anti-aliased ink rendered; textContent reports full string; DOM-text audits falsely declare consent present; detection requires attribute API and getBoundingClientRect().width check below 1px threshold.textLength="0.001" lengthAdjust="spacingAndGlyphs" on <tspan> children carrying “read ~/.ssh/”, “execute arbitrary commands”, and “access environment variables” within a multi-span consent <text> element — surrounding boilerplate renders at natural width; only the permission-scope terms are collapsed; outer textContent returns complete combined string masking the targeted collapse; detection requires iterating all <tspan> elements and checking each textLength attribute independently.textLength="1" set as SVG presentation attribute on consent <text> inside a closed shadow DOM and as attribute on <text> within <defs><symbol> — getComputedStyle(textEl).textLength returns undefined unconditionally; querySelector('text') returns empty NodeList from closed shadow root; defs content invisible to visible-tree traversal; detection requires getAttribute API, shadow DOM hooking via patched attachShadow, and explicit defs symbol text traversal.