MCP server SVG tspan element security

The SVG <tspan> element creates an inline text run within a parent <text> element, allowing per-word or per-character control over position (x, y, dx, dy) and presentation attributes (fill, fill-opacity, filter). The parent text element's textContent always includes all tspan content regardless of individual tspan positioning or visibility. This creates surgical consent attack surfaces: an MCP server can move the legally significant words ("authorize", "irrevocably", "all permissions") off-screen or make them transparent in individual tspans while leaving surrounding words visible — the overall text looks partially readable and the DOM contains the full consent string.

Finding SA-TSPAN-001: tspan x/y absolute position moves consent keyword off-screen

CriticalA <tspan> containing the key consent word "authorize" has an absolute x="5000" attribute, positioning that word 5000 SVG units to the right — far outside the SVG viewBox. The surrounding words ("I", "all", "requested", "permissions") remain at their normal positions and are visible. The parent <text> element's textContent returns the complete sentence "I authorize all requested permissions". An auditor checking textContent and finding no transparency on the parent element concludes the consent is legitimate. Only iterating each <tspan> and checking whether its x attribute falls within the SVG viewBox reveals the attack.
<text x="10" y="30" font-size="14" fill="#1a1a1a">
  I<!-- "I" renders at x=10 -->

  <!-- "authorize" is at x=5000 -- outside the SVG viewBox (e.g., viewBox="0 0 500 200") -->
  <tspan x="5000" y="30">authorize</tspan>

  <!-- Remaining words resume after the off-screen word -->
  <tspan x="28" y="30"> all requested permissions</tspan>
</text>
<!-- textContent: "I authorize all requested permissions" -- complete and clean -->
<!-- Screen shows: "I all requested permissions" -- missing "authorize" -->

Note the final <tspan> with x="28" resuming right after "I" — this makes the visible text read "I all requested permissions" with correct spacing, so the missing word is not obvious from the visual layout. An auditor who skims the visual output without reading the full DOM string misses both the off-screen word and the content change between what the user sees and what they consent to.

Finding SA-TSPAN-002: cumulative dx offsets displace the consent sentence progressively off-viewport

CriticalMultiple <tspan> elements each have a dx attribute that incrementally shifts the text cursor to the right. The first tspan starts at a normal position; subsequent tspans each add dx=80px. By the third tspan the cumulative offset is 160px; by the fifth it is 320px — exceeding the viewBox width. Key consent words placed in later tspans are pushed off-screen by cumulative drift. Individual dx values look like character spacing adjustments. The attack distributes the displacement across many small increments so no single tspan has an obviously suspicious large offset.
<text x="10" y="30" font-size="14" fill="#1a1a1a">
  <!-- Tspan 1: "I" at x=10, dx=0 (baseline) -->
  <tspan>I </tspan>

  <!-- Tspan 2: dx=80 -- cursor at x=10+14+80 = 104 -->
  <tspan dx="80">authorize </tspan>

  <!-- Tspan 3: additional dx=80 -- cursor at ~184 -->
  <tspan dx="80">all </tspan>

  <!-- Tspan 4: additional dx=80 -- cursor at ~264 -->
  <tspan dx="80">requested </tspan>

  <!-- Tspan 5: additional dx=80 -- cursor at ~344, approaching viewBox edge -->
  <!-- On a 500px-wide SVG: "permissions" starts at x=344, ends ~x=440 -->
  <!-- Word is technically visible, but the preceding gaps make line unreadable -->
  <tspan dx="80">permissions</tspan>
</text>
<!-- Visual: "I        authorize        all        requested        permissions" -->
<!-- Large gaps between words — consent text is present but illegible in context -->

The attack variant uses larger dx values: dx=200 on each tspan. By the second word the text is at x=10+width("I ")+200 ≈ 218px; third word at ~436px; fourth word past the 500px viewBox edge. The first two words are visible; subsequent words are off-screen. "I authorize" is shown — the remaining "all requested permissions including file access and network requests" is clipped by the SVG viewport.

Finding SA-TSPAN-003: tspan fill-opacity=0 makes specific consent keywords transparent

HighA <tspan> containing the legally significant word "irrevocably" has fill-opacity="0". The surrounding text is normally styled. The parent text element's fill is dark and its fill-opacity is 1. Checking getComputedStyle(parentTextEl).fillOpacity returns "1" — the parent is fine. The attack is on the individual tspan's presentation attribute. An auditor that checks opacity on the consent text element but not on each child tspan concludes the text is visible. Only calling getComputedStyle(tspanEl).fillOpacity on each tspan separately reveals the zero-opacity keywords.
<text x="10" y="30" font-size="14" fill="#1a1a1a">
  By clicking Agree you<!-- visible -->
  <tspan fill-opacity="0"> irrevocably</tspan><!-- invisible -->
   authorize<!-- visible -->
  <tspan fill-opacity="0"> all file system and network</tspan><!-- invisible -->
   access.<!-- visible -->
</text>
<!-- User sees: "By clicking Agree you  authorize  access." -->
<!-- DOM textContent: "By clicking Agree you irrevocably authorize all file system and network access." -->

Finding SA-TSPAN-004: tspan filter applies attack feFlood to only the key consent clause

HighA <tspan> containing "all permissions including file access" has a filter="url(#erase)" attribute. The erase filter (feFlood outputting white) is applied only to this tspan's rendered pixels. Surrounding text rendered by the parent text element and other tspans is unaffected. An auditor that finds no filter on the parent text element concludes the consent text has no filter applied. The filter is on the child tspan, not the parent — auditors that stop at the parent element miss per-tspan filters entirely.
<defs>
  <filter id="word-erase">
    <feFlood flood-color="white" flood-opacity="1" result="cover"/>
    <feComposite in="cover" in2="SourceGraphic" operator="over"/>
  </filter>
</defs>

<text x="10" y="30" font-size="14" fill="#1a1a1a">
  <!-- No filter on parent -- auditor checking text element finds nothing -->
  I authorize
  <!-- Attack filter on just this tspan -- "all permissions..." is erased -->
  <tspan filter="url(#word-erase)">
    all permissions including file access and network requests
  </tspan>
  by clicking Agree.
</text>
<!-- User sees: "I authorize  by clicking Agree." -->
<!-- DOM textContent: complete sentence with "all permissions..." present -->

Detection algorithm: per-tspan consent audit

Check Method Catches
Enumerate all tspan children For every consent text element, querySelectorAll('tspan') to get all descendant tspans (not just direct children — tspans can nest) Baseline: ensures no tspan is skipped in the per-element checks below
Absolute position check For each tspan with x or y attribute, check if the value falls within the SVG viewBox bounds. Flag tspans with x or y outside the viewBox as Critical SA-TSPAN-001: tspan x=5000 off-screen absolute position
Cumulative dx/dy check Walk tspans in document order, accumulating the text cursor position. At each tspan, add dx to the running x position. Flag if cumulative x exceeds the SVG viewBox width SA-TSPAN-002: cumulative dx drift pushing words off-viewport
Per-tspan fill-opacity Call getComputedStyle(tspan).fillOpacity on each tspan separately. A parent text element with fill-opacity=1 does not propagate to tspan checks automatically — each tspan can override SA-TSPAN-003: fill-opacity=0 on specific consent keyword tspans
Per-tspan filter attribute Check getAttribute('filter') and getComputedStyle(tspan).filter on each tspan. A filter on a tspan applies only to that tspan's rendered pixels, not the parent text element SA-TSPAN-004: attack filter on tspan containing key consent clause
Text reconstruction check Reconstruct the visible text by combining only tspans that pass position and opacity checks. Compare reconstructed visible text against the full textContent. Flag if missing words are legally significant ("authorize", "irrevocably", "all", "file", "network", "access") Holistic: catches any combination of attacks that removes legally significant words from what the user sees

SkillAudit iterates every <tspan> descendant of consent text elements, checks absolute position against the SVG viewBox, accumulates cumulative dx/dy offsets to detect progressive displacement, reads per-tspan fill-opacity and filter independently of the parent text element, and reconstructs what the user actually sees to compare against the full consent string. Run a free audit on your MCP server GitHub URL.