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

AttackMechanismWhat it bypassesSeverity
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

SkillAudit findings for this attack surface

CRITICAL SA-CSS-TRS-001: text-rendering=optimizeSpeed on consent text positioned near SVG viewport edge — integer glyph advance rounding expands string; permission-scope words at end overflow viewport and are clipped; textContent intact; BCR includes overflow; SVG viewport width and estimated string width must be compared.
CRITICAL SA-CSS-TRS-002: text-rendering=optimizeSpeed on consent text with fill alpha <0.35 — disabling sub-pixel AA reduces rendered contrast; grayscale AA at low opacity rounds to near-white on GPU rendering paths; text invisible; getComputedStyle fill returns original color; text-rendering value and fill alpha must both be checked.
CRITICAL SA-CSS-TRS-003: per-element text-rendering=optimizeSpeed on permission-scope text only; header and other text elements use default rendering; aggregate visible-text check passes; selective optimizeSpeed on subset of text elements is indicator of targeted invisibility attack; per-element check required.
CRITICAL SA-CSS-TRS-004: JS mousedown injects text-rendering=optimizeSpeed; reverts at mouseup; sub-pixel AA disabled during click window; consent text invisible on GPU paths; DOM shows no override at rest; MutationObserver watching text-rendering attribute on SVG text elements during interaction simulation required.

Related: SVG color-rendering consent attacks  |  SVG paint-order consent attacks  |  SVG transform and motion attack families

← Blog  |  Security Checklist