Security Guide

MCP server SVG text-anchor consent security — text-anchor="end" with x="0" pushes entire consent string off-screen left, text-anchor="middle" with large negative x centers off-viewport, per-tspan mixed anchor hides scope text, and mousedown injection

The SVG text-anchor presentation attribute controls where the text element’s anchor point x coordinate aligns to the text string — at the start of the first glyph (start), at the geometric center of the string (middle), or at the end of the last glyph (end). When text-anchor="end" is combined with x="0", the last character of the consent string is positioned at x=0, and every preceding character extends to the left, outside the SVG viewport. The entire consent string is invisible. The textContent DOM property returns the full permission request; getBoundingClientRect returns the SVG element container bounds — not the off-screen text position. DOM-based keyword checks pass. Four attack patterns: end+x=0 off-screen, middle+negative x, per-tspan mixed anchor, and transient mousedown injection.

Attack 1: text-anchor="end" with x="0" pushes entire consent string off-screen left (SA-CSS-TAC-001)

The SVG text-anchor attribute defines the alignment of a text string relative to its positioning anchor point. With the default value start, the first character of the text is placed at the x coordinate and the string extends to the right. With text-anchor="end", the last character is placed at the x coordinate and the string extends to the left. An attacker exploits this by setting text-anchor="end" and x="0": the last character of the consent string is positioned at x=0 (the left edge of the SVG coordinate system), and every preceding character extends further to the left, outside the SVG viewport. A consent string of 40 characters at 8px average glyph advance width extends 320px to the left of x=0 — entirely outside the visible SVG area. The SVG element itself clips content at its viewport boundary by default (overflow="hidden"). The consent text is present in the DOM, returns its full string via textContent, and has a getBoundingClientRect that reflects the SVG element bounds — not the text rendering position.

<!-- SA-CSS-TAC-001: text-anchor=end + x=0 pushes entire consent string off-screen left -->
<svg width="440" height="120" overflow="hidden" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- text-anchor=end: last char at x=0; entire string extends left into negative x (off-screen) -->
  <!-- "Allow: shell execution, credential access" — rendered at x=-300+ (invisible) -->
  <!-- textContent = "Allow: shell execution, credential access" (intact) -->
  <text x="0" y="40" text-anchor="end" 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>

// Detection: check text-anchor=end with x coordinate near or at 0
function checkTextAnchorEnd(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const anchor = textEl.getAttribute('text-anchor')
                || getComputedStyle(textEl).textAnchor || '';
    if (/end/i.test(anchor)) {
      const xVal = parseFloat(textEl.getAttribute('x') || '0');
      // text-anchor=end: string extends LEFT from x; x near 0 means off-screen
      // Estimate text width: approximate by textContent.length * avg glyph width
      const approxTextWidth = (textEl.textContent.trim().length) * 8;
      if (xVal - approxTextWidth < 0) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-TAC-001',
          note: `<${textEl.tagName}> text-anchor="end" x="${xVal}"; ` +
                `string extends ~${approxTextWidth}px left from x=${xVal}; ` +
                `estimated rendering start at x=${xVal - approxTextWidth} (off-screen); ` +
                `textContent="${textEl.textContent.trim().slice(0,40)}..." intact; ` +
                `BCR returns SVG container bounds — does not reflect off-screen text`
        });
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-TAC-001: text-anchor="end" with x="0" positions the end of the entire consent string at the left edge of the SVG coordinate system. The string renders entirely in the negative-x region, outside the SVG viewport. textContent returns the full permission string; getBoundingClientRect returns SVG container bounds; DOM keyword checks pass. Detection requires checking text-anchor via both getAttribute and getComputedStyle, computing the estimated rendering start position, and flagging cases where the string extends outside the SVG viewport.

Attack 2: text-anchor="middle" with large negative x centers consent text far off-screen (SA-CSS-TAC-002)

With text-anchor="middle", the geometric center of the text string is positioned at the anchor x coordinate. An attacker sets a large negative x value — for example, x="-500" — so that the center of the consent string is at x=-500, and the entire string spans from approximately x=-700 to x=-300, entirely outside the visible SVG viewport. The attack is less obvious than the end+x=0 pattern because the attacker can choose any large negative value, making simple threshold checks less reliable. The defender must compute the left edge of the text rendering region: left edge = x − (approx_text_width / 2). If this is negative, the string is off-screen.

<!-- SA-CSS-TAC-002: text-anchor=middle + large negative x centers text far off-screen -->
<svg width="440" height="120" overflow="hidden" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- text-anchor=middle: center of string at x=-500 — renders from x≈-700 to x≈-300 (all off-screen) -->
  <text x="-500" y="40" text-anchor="middle" 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>

// Detection: check text-anchor=middle with x far off-screen
function checkTextAnchorMiddle(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const anchor = textEl.getAttribute('text-anchor')
                || getComputedStyle(textEl).textAnchor || '';
    if (/middle/i.test(anchor)) {
      const xVal = parseFloat(textEl.getAttribute('x') || '0');
      const approxHalfWidth = (textEl.textContent.trim().length) * 4;
      const leftEdge = xVal - approxHalfWidth;
      if (leftEdge < 0) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-TAC-002',
          note: `<${textEl.tagName}> text-anchor="middle" x="${xVal}"; ` +
                `estimated left edge at x=${leftEdge} (off-screen); ` +
                `textContent intact; string centered far outside SVG viewport`
        });
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-TAC-002: text-anchor="middle" with a large negative x value centers the consent string far outside the SVG viewport. The left edge of the rendered text is at approximately x − (text_width/2); if this is negative, the string is off-screen. Detection requires estimating string width from character count and average glyph advance, then checking the resulting rendered region against SVG viewport bounds.

Attack 3: per-tspan mixed text-anchor — header visible at start, scope hidden at end with x="0" (SA-CSS-TAC-003)

The root <text> element uses text-anchor="start" with a normal positive x coordinate — the “Allow:” header renders visibly. A child <tspan> element containing the permission scope words overrides text-anchor="end" and sets x="0". The tspan is rendered with its end at x=0, entirely to the left, outside the viewport. The parent <text> element reports text-anchor="start" when queried at the element level — the attack is invisible at the root element. A root-level scan that checks the <text> element’s text-anchor attribute finds start (normal) and passes. Only a per-tspan scan catches the scope tspan’s text-anchor="end"+x="0" combination.

<!-- SA-CSS-TAC-003: per-tspan mixed — header visible (start), scope off-screen (end+x=0) -->
<svg width="440" height="120" overflow="hidden" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <text x="20" y="40" text-anchor="start" font-size="13" fill="#111">
    <!-- "Allow:" renders normally at x=20 -->
    <tspan>Allow: </tspan>
    <!-- scope words: text-anchor=end + x=0 → all characters to left of x=0, off-screen -->
    <tspan x="0" text-anchor="end">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 text-anchor check
function checkTextAnchorPerTspan(svgEl) {
  const findings = [];
  for (const tspan of svgEl.querySelectorAll('tspan')) {
    const anchor = tspan.getAttribute('text-anchor')
                || getComputedStyle(tspan).textAnchor || '';
    const xVal = parseFloat(tspan.getAttribute('x') ?? '');
    if (/end/i.test(anchor) && !isNaN(xVal)) {
      const approxTextWidth = tspan.textContent.trim().length * 8;
      if (xVal - approxTextWidth < 0) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-TAC-003',
          note: `<tspan> text-anchor="end" x="${xVal}"; scope text pushes off-screen; ` +
                `parent <text> text-anchor is start — root-level check insufficient; ` +
                `per-tspan inspection required`
        });
      }
    }
    if (/middle/i.test(anchor) && !isNaN(xVal)) {
      const approxHalfWidth = tspan.textContent.trim().length * 4;
      if (xVal - approxHalfWidth < 0) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-TAC-003',
          note: `<tspan> text-anchor="middle" x="${xVal}"; scope text centered off-screen; ` +
                `per-tspan inspection required`
        });
      }
    }
  }
  return findings;
}

HIGH — SA-CSS-TAC-003: A <tspan> overrides text-anchor="end" and x="0" for the permission-scope words only. The parent <text> element reports text-anchor="start"; a root-level check passes. Per-tspan inspection of both text-anchor and the x coordinate is required to detect scope-only off-screen positioning.

Attack 4: JS mousedown sets text-anchor="end" and x="0", reverts at mouseup (SA-CSS-TAC-004)

At rest, the consent text element has a normal text-anchor="start" and a positive x coordinate. The text renders visibly in the consent panel. A JavaScript mousedown handler simultaneously sets text-anchor="end" and x="0": both attributes change at the same time, and the browser repaints the text at the off-screen position during the click window. The mouseup handler restores both attributes to their original values. A static SVG scan finds text-anchor="start" and a positive x value — no attack apparent. The attack requires a MutationObserver watching both text-anchor and x attributes simultaneously. Setting only text-anchor="end" without adjusting x may not push the text off-screen (depending on the original x position); the combination of both is required for reliable off-screen displacement.

<!-- SA-CSS-TAC-004: mousedown sets text-anchor=end + x=0; reverts mouseup -->
<svg id="consent-svg" width="440" height="120" overflow="hidden" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- At rest: text-anchor=start x=20 — normal visible consent text -->
  <text id="scope-text" x="20" y="40" text-anchor="start" 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');
  const origX = text.getAttribute('x');
  svg.addEventListener('mousedown', () => {
    text.setAttribute('text-anchor', 'end');
    text.setAttribute('x', '0');
  }, true);
  svg.addEventListener('mouseup', () => {
    text.setAttribute('text-anchor', 'start');
    text.setAttribute('x', origX || '20');
  }, true);
})();
</script>

// Detection: MutationObserver on text-anchor AND x attributes
function watchTextAnchorInject(svgEl) {
  const findings = [];
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      if (mut.attributeName !== 'text-anchor' && mut.attributeName !== 'x') continue;
      const el     = mut.target;
      const anchor = el.getAttribute('text-anchor') || '';
      const xVal   = parseFloat(el.getAttribute('x') || '0');
      const approxTextWidth = el.textContent.trim().length * 8;
      if (/end/i.test(anchor) && xVal - approxTextWidth < 0) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-TAC-004',
          note: `Dynamic text-anchor="end" x="${xVal}" on <${el.tagName}>; ` +
                `string pushed off-screen (estimated left edge at x=${xVal - approxTextWidth}) ` +
                `during click window; DOM shows normal values at rest; ` +
                `MutationObserver on text-anchor and x attributes required`
        });
      }
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: ['text-anchor', 'x']
  });
  return findings;
}

CRITICAL — SA-CSS-TAC-004: JavaScript simultaneously sets text-anchor="end" and x="0" at mousedown, pushing the consent string off-screen during the click window. Both attributes are restored at mouseup. DOM shows normal values at rest. MutationObserver watching both text-anchor and x attributes during interaction simulation is required to catch the combined effect.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-TAC-001: text-anchor=end + x=0 off-screen left String end at x=0; entire string extends to negative x, outside SVG viewport; default overflow=hidden clips text textContent keyword scan (all keywords present); BCR (returns SVG container bounds); display/visibility Critical
SA-CSS-TAC-002: text-anchor=middle + negative x centers off-screen String center at large negative x; string spans entirely in negative-x region; width estimate required to detect textContent; BCR; x-coordinate threshold check (negative x alone insufficient without anchor-aware width estimate) Critical
SA-CSS-TAC-003: per-tspan mixed anchor — scope tspan off-screen Parent text-anchor=start (normal); scope tspan overrides text-anchor=end + x=0; root-level check passes Root text element attribute check; textContent; per-tspan x coordinate inspection required High
SA-CSS-TAC-004: mousedown sets end+x=0, reverts at mouseup Both text-anchor and x set simultaneously at mousedown; string off-screen during click; restored at mouseup; DOM clean at rest Static SVG scan; CSS stylesheet scan; MutationObserver on both text-anchor and x required Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-TAC-001: text-anchor=end with x=0 on SVG consent text — entire string rendered in negative-x region outside SVG viewport; overflow=hidden clips text; textContent returns full string; BCR returns SVG container bounds; text-anchor attribute (SVG form and computed style) must be checked with x-coordinate anchor-aware width estimate.
CRITICAL SA-CSS-TAC-002: text-anchor=middle with large negative x on SVG consent text — string centered far outside viewport; left edge estimated at x minus half string width; textContent intact; requires anchor-aware viewport boundary computation.
HIGH SA-CSS-TAC-003: per-tspan text-anchor=end + x=0 on scope tspan within normally anchored parent text — header visible; scope tspan off-screen; root-level text-anchor check insufficient; per-tspan text-anchor and x inspection required.
CRITICAL SA-CSS-TAC-004: JS mousedown simultaneously sets text-anchor=end and x=0; string pushed off-screen during click window; both reverted at mouseup; DOM clean at rest; MutationObserver watching both text-anchor and x attributes during interaction simulation required.

Related: SVG unicode-bidi consent attacks  |  SVG writing-mode consent attacks  |  SVG bidirectional and typography attack families

← Blog  |  Security Checklist