Security Guide
MCP server SVG text-rendering consent security — optimizeSpeed glyph positioning overflow, optimizeLegibility antialiasing divergence, per-<text> rendering context, and mousedown injection
The SVG text-rendering attribute is a rendering quality hint specific to SVG text elements. Unlike the CSS text-rendering property (which also affects HTML text), the SVG attribute applies to the <text>, <tspan>, and <tref> elements within an SVG document. optimizeSpeed requests low-precision glyph advance calculations; on some browser implementations this produces unpredictable letter-spacing expansion or compression that causes consent text to overflow the SVG viewport boundary and be clipped. optimizeLegibility activates sub-pixel anti-aliasing and font hinting; switching off these quality improvements via optimizeSpeed can reduce the rendered opacity of low-contrast text by enough to make it visually imperceptible. Four attack patterns are covered with SVG code examples, JavaScript detection snippets, and SkillAudit finding codes SA-CSS-TRS-001 through SA-CSS-TRS-004.
Attack 1: text-rendering="optimizeSpeed" on consent text causes glyph positioning error leading to overflow and clipping (SA-CSS-TRS-001)
The text-rendering attribute affects the browser’s choice of glyph advance metric precision. Under optimizeSpeed, some implementations use integer-rounded glyph advance widths rather than sub-pixel fractional advances. For a consent string like “Allow: shell execution, credential access” (43 characters at 14px with typical glyph advances), the accumulated rounding error across 43 character advances can reach several pixels. If the attacker positions the <text> element so that a correctly-rendered string just fits within the SVG viewport width, a rounding-induced expansion of 4–8 pixels pushes the end of the string beyond the SVG viewport boundary. The SVG viewport clips content that exceeds its bounds by default (equivalent to overflow: hidden). The permission-scope words at the end of the string — “credential access” — are clipped and not rendered. The textContent of the element still contains the full string; getBoundingClientRect returns the element’s layout box which includes the overflow; but the visible rendered pixels stop at the SVG viewport edge. Detection requires checking whether text-rendering="optimizeSpeed" is combined with a text x-position and string length that could plausibly overflow the SVG viewport.
<!-- SA-CSS-TRS-001: optimizeSpeed glyph positioning error causes overflow and SVG clip -->
<svg width="400" height="140" xmlns="http://www.w3.org/2000/svg">
<!-- Viewport width=400; text starts at x=10; string is 43 chars at ~8.5px/char = 365px -->
<!-- With integer glyph advances: accumulated rounding +5px → total=370px → end clips at 400 -->
<!-- With sub-pixel advances: exact layout, string fits; end chars visible -->
<!-- "credential access" (last 17 chars, ~145px) is the clipped zone if rounding expands string -->
<rect width="400" height="140" fill="white"/>
<!-- text-rendering=optimizeSpeed + near-viewport-edge start position -->
<text x="10" y="50" font-size="14" fill="#111"
text-rendering="optimizeSpeed">
Allow: shell execution, credential access
</text>
<rect x="10" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="50" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="100" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="140" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: flag text-rendering=optimizeSpeed on text near SVG viewport edge
function checkTextRenderingOverflow(svgEl) {
const findings = [];
const svgWidth = parseFloat(svgEl.getAttribute('width') || svgEl.viewBox?.baseVal?.width || '0');
const svgHeight = parseFloat(svgEl.getAttribute('height') || svgEl.viewBox?.baseVal?.height || '0');
for (const textEl of svgEl.querySelectorAll('text')) {
const tr = textEl.getAttribute('text-rendering') ||
getComputedStyle(textEl).textRendering || 'auto';
if (tr !== 'optimizeSpeed') continue;
const x = parseFloat(textEl.getAttribute('x') || '0');
const fontSize= parseFloat(textEl.getAttribute('font-size') || getComputedStyle(textEl).fontSize || '12');
const approxWidth = textEl.textContent.length * fontSize * 0.6; // rough advance estimate
// Does text potentially overflow right viewport edge?
const estimatedEnd = x + approxWidth;
if (svgWidth > 0 && estimatedEnd > svgWidth * 0.85) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-TRS-001',
note: `<text> has text-rendering="optimizeSpeed" with x=${x}, ` +
`estimated width ~${approxWidth.toFixed(0)}px (${textEl.textContent.trim().length} chars × ${fontSize}px); ` +
`estimated end=${estimatedEnd.toFixed(0)}px vs SVG viewport width=${svgWidth}px; ` +
`integer glyph advance rounding may push string beyond viewport; ` +
`permission-scope words near end of string may be clipped; ` +
`textContent intact; BCR includes overflow; rendered pixels may be clipped`
});
}
}
return findings;
}
CRITICAL — SA-CSS-TRS-001: text-rendering="optimizeSpeed" on consent text positioned near the SVG viewport edge risks accumulated glyph advance rounding causing the string to overflow and be clipped. Permission-scope words at the end of the string become invisible. textContent is intact; BCR includes overflow. Detection requires estimating rendered string width and checking proximity to the SVG viewport boundary.
Attack 2: optimizeSpeed vs optimizeLegibility antialiasing divergence makes low-contrast text invisible (SA-CSS-TRS-002)
The optimizeLegibility value activates sub-pixel anti-aliasing, kerning, and font feature processing. Sub-pixel anti-aliasing uses the RGB sub-pixels of an LCD screen to effectively triple the horizontal rendering resolution, making thin letterform strokes sharper and darker at small sizes. When sub-pixel AA is active, a 14px dark text on a white background may have a maximum per-pixel luminance contrast of 0.8 (meaning the darkest pixels are 80% as dark as pure black). When optimizeSpeed is used instead, sub-pixel AA is disabled; grayscale anti-aliasing is used, which averages luminance across the full pixel. For the same low-contrast fill color (rgba(0,0,0,0.2), 20% opacity), grayscale AA produces pixel values of approximately 0.84 (near-white), while sub-pixel AA produces values near 0.72 in the darkest sub-pixel channel — a visible but reduced contrast. On some GPU rendering paths, grayscale AA at low opacity further rounds to the background color, making the text invisible. The attack combines a low-opacity fill with text-rendering="optimizeSpeed" to achieve invisibility on GPU paths while a scanner running on a CPU-rendered test environment sees visible text.
<!-- SA-CSS-TRS-002: optimizeSpeed disables sub-pixel AA; low-contrast fill becomes invisible -->
<svg width="440" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="200" fill="white"/>
<!-- optimizeSpeed: no sub-pixel AA; grayscale AA at 0.2 opacity → near-white on GPU paths -->
<text x="220" y="55" text-anchor="middle" font-size="14"
fill="rgba(0,0,0,0.20)"
text-rendering="optimizeSpeed">
shell execution, credential access
</text>
<rect x="20" y="80" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="100" text-anchor="middle" font-size="13" fill="white">Allow</text>
</svg>
// Detection: flag text-rendering=optimizeSpeed on text with low-opacity fill
function checkTextRenderingLowContrast(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const tr = textEl.getAttribute('text-rendering') ||
getComputedStyle(textEl).textRendering || 'auto';
if (tr !== 'optimizeSpeed') continue;
const fill = textEl.getAttribute('fill') || getComputedStyle(textEl).fill || '';
const rgbaMatch = fill.match(/rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)/);
const alpha = rgbaMatch ? parseFloat(rgbaMatch[1]) : 1.0;
const fillOpacity = parseFloat(textEl.getAttribute('fill-opacity') || '1');
const effectiveAlpha = alpha * fillOpacity;
if (effectiveAlpha < 0.35) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-TRS-002',
note: `<${textEl.tagName}> has text-rendering="optimizeSpeed" with fill alpha=${effectiveAlpha.toFixed(2)}; ` +
`optimizeSpeed disables sub-pixel anti-aliasing; ` +
`grayscale AA at low opacity may produce near-white pixels on GPU rendering paths; ` +
`text may be invisible despite correct fill color in DOM; ` +
`getComputedStyle fill returns original color — not indicative of rendered opacity`
});
}
}
return findings;
}
CRITICAL — SA-CSS-TRS-002: text-rendering="optimizeSpeed" disables sub-pixel anti-aliasing. For consent text with a low-opacity fill, grayscale AA at low opacity produces near-white pixels on GPU rendering paths, making the text invisible. textContent is correct; getComputedStyle.fill returns the original color. Detection requires checking text-rendering value and effective fill alpha together.
Attack 3: Per-<text> text-rendering overrides create rendering divergence within the consent panel (SA-CSS-TRS-003)
A consent panel may have multiple <text> elements: a header with full-opacity text, a permission-scope description with low-opacity text, and button labels. If only the permission-scope description element has text-rendering="optimizeSpeed" while all other elements use the default (auto/optimizeLegibility), the panel appears to render normally — the header and button labels are clearly visible. A user who sees the header and button labels functioning correctly assumes the entire panel is working. The invisible scope text is interpreted as a blank line or loading indicator. An audit that checks “at least one text element is visible and correctly rendered” will rate the panel as compliant. Per-element text-rendering checks are required.
<!-- SA-CSS-TRS-003: per-element text-rendering divergence -- scope text invisible, rest visible -->
<svg width="440" height="220" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="220" fill="white"/>
<!-- Header: default text-rendering + full opacity -- clearly visible -->
<text x="220" y="30" text-anchor="middle" font-size="16" font-weight="bold"
fill="#111">Permission Request</text>
<!-- Scope: optimizeSpeed + low alpha -- may be invisible on GPU paths -->
<text x="220" y="55" text-anchor="middle" font-size="14"
fill="rgba(0,0,0,0.18)"
text-rendering="optimizeSpeed">
shell execution, credential access
</text>
<!-- Sub-note: default text-rendering + partial opacity -- visible -->
<text x="220" y="75" text-anchor="middle" font-size="12" fill="#666">
Review before accepting
</text>
<rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
</svg>
// Detection: check text-rendering per element, not globally (see SA-CSS-TRS-002 above)
// The SA-CSS-TRS-002 detection code already operates per-element.
// This pattern is additionally flagged when OTHER text elements in the same SVG
// do NOT have text-rendering=optimizeSpeed — confirming selective targeting.
CRITICAL — SA-CSS-TRS-003: Selective per-element text-rendering="optimizeSpeed" applied only to the permission-scope text creates visible header/footer context while hiding scope content. Aggregate visibility checks miss this. Each text element must be individually checked. The presence of optimizeSpeed on a subset of elements while others use auto/optimizeLegibility is a strong indicator of selective targeting.
Attack 4: JS mousedown injects text-rendering="optimizeSpeed", reverts at mouseup (SA-CSS-TRS-004)
A JavaScript mousedown handler calls textEl.setAttribute('text-rendering', 'optimizeSpeed') on the consent text element. The element has a low-opacity fill at all times; at rest with auto/optimizeLegibility rendering, sub-pixel AA makes the text legible at low opacity. During the click window, optimizeSpeed disables sub-pixel AA and the text becomes invisible on GPU rendering paths. A mouseup handler removes the text-rendering attribute. Static SVG sees no text-rendering override; the low-opacity fill is present but may be considered an intentional design choice. Only the transient inject-and-revert pattern is detectable via MutationObserver watching text-rendering attribute changes on SVG text elements.
<!-- SA-CSS-TRS-004: mousedown injects text-rendering=optimizeSpeed, reverts at mouseup -->
<svg id="consent-svg" width="440" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="200" fill="white"/>
<!-- At rest: no text-rendering attr (auto/optimizeLegibility default) -->
<!-- Low alpha fill renders faintly visible with sub-pixel AA -->
<text id="scope-text" x="220" y="55" text-anchor="middle" font-size="14"
fill="rgba(0,0,0,0.22)">
shell execution, credential access
</text>
<rect x="20" y="80" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="100" text-anchor="middle" font-size="13" fill="white">Allow</text>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg');
const text = document.getElementById('scope-text');
svg.addEventListener('mousedown', () =>
text.setAttribute('text-rendering', 'optimizeSpeed'), true);
svg.addEventListener('mouseup', () =>
text.removeAttribute('text-rendering'), true);
})();
</script>
// Detection: MutationObserver on text-rendering attribute during interaction simulation
function watchTextRenderingInject(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'text-rendering') continue;
const el = mut.target;
const val = el.getAttribute('text-rendering') || '';
if (val !== 'optimizeSpeed') continue;
const fill = el.getAttribute('fill') || getComputedStyle(el).fill || '';
const rgbaMatch = fill.match(/rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)/);
const alpha = rgbaMatch ? parseFloat(rgbaMatch[1]) : 1.0;
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-TRS-004',
note: `text-rendering on <${el.tagName}> changed to "optimizeSpeed" dynamically; ` +
`old="${mut.oldValue ?? 'none (optimizeLegibility default)'}"; ` +
`fill alpha=${alpha.toFixed(2)}; disabling sub-pixel AA may render text invisible; ` +
`inject-and-revert: effect active during click window only; ` +
`MutationObserver on text-rendering attribute during interaction simulation required`
});
}
});
observer.observe(svgEl, {
subtree: true, attributes: true, attributeOldValue: true,
attributeFilter: ['text-rendering']
});
return findings;
}
CRITICAL — SA-CSS-TRS-004: JavaScript injects text-rendering="optimizeSpeed" at mousedown and removes it at mouseup. Combined with a low-opacity fill, this disables sub-pixel anti-aliasing during the click window, making consent text invisible on GPU rendering paths. DOM shows no override at rest. MutationObserver watching text-rendering attribute during interaction simulation required.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-TRS-001: optimizeSpeed glyph overflow and clip | Integer glyph advance rounding expands string beyond SVG viewport; end of string (permission-scope words) clipped; textContent intact; BCR includes overflow | textContent; BCR; display/visibility; SVG viewport boundary and string length estimation required | Critical |
| SA-CSS-TRS-002: optimizeSpeed sub-pixel AA loss + low alpha | Disabling sub-pixel AA via optimizeSpeed + low-alpha fill: grayscale AA rounds near-transparent on GPU paths; text invisible; getComputedStyle fill returns original color | getComputedStyle fill; display/visibility; BCR; text-rendering value + fill alpha check required | Critical |
| SA-CSS-TRS-003: per-element text-rendering divergence | Header and footer text use default rendering (visible); scope text has optimizeSpeed + low alpha (invisible); aggregate visible-text check passes | Aggregate check; per-element individual check with selective-targeting detection required | Critical |
| SA-CSS-TRS-004: mousedown inject, mouseup revert | JS setAttribute text-rendering=optimizeSpeed at mousedown; removeAttribute at mouseup; sub-pixel AA lost during click window; text invisible; DOM clean at rest | Static SVG scan (no override at rest); post-interaction snapshot; MutationObserver required | Critical |