Security Guide

MCP server SVG tspan y offset consent security — y="-500" places consent text 500 SVG units above the viewport (clipped by overflow:hidden), y="5000" displaces below the viewport, per-tspan scope hides consent while header renders at normal y, and mousedown injects the displacement during the click window

The SVG <tspan> element’s y attribute sets an absolute vertical coordinate for the tspan’s baseline in the current SVG user-unit coordinate system. Setting y="-500" positions the tspan baseline 500 SVG user units above the coordinate origin. In the standard SVG coordinate system, the origin is at the top-left corner with y increasing downward, so y=-500 places text 500 CSS pixels above the SVG element’s top edge. The SVG specification’s default overflow value of hidden clips all content outside the viewport to zero rendered pixels. The parent <text> element and its DOM properties remain intact — non-zero getBoundingClientRect(), full textContent, normal computed style — while the consent tspan is completely absent from the rendered output. The mirror variant y="5000" displaces text far below the viewport. Four attack patterns: y="-500" above viewport, y="5000" below viewport, per-tspan scope, and mousedown injection.

Attack 1: tspan y="-500" — consent tspan baseline 500 SVG units above the viewport, clipped by overflow:hidden (SA-CSS-TYO-001)

The y attribute on a <tspan> element sets the absolute block-axis position of the tspan’s text baseline in SVG user-unit space. This is an absolute coordinate: it overrides the inline cursor’s y position and places characters at the exact specified y value, independent of the parent <text> element’s own y attribute. With y="-500", the tspan baseline is 500 SVG user units above the coordinate origin. In an SVG with height="120" and no custom viewBox transform, the coordinate system maps 1:1 to CSS pixels; y=-500 is 500px above the SVG element’s top edge. The SVG specification (SVG 2, §12.5) states that the initial value of the overflow property on <svg> elements is hidden: content at y < 0 is clipped and not rendered.

The attack leaves all parent-level checks passing. The parent <text> element has a layout box at its declared x and y position; the layout engine may or may not factor in the tspan’s off-screen y when computing the parent BCR, but the result is typically non-zero. The textContent of the parent returns the full concatenation of all tspan content. getComputedStyle(tspan).visibility returns visible; display returns inline; opacity is 1. A scanner relying on these per-element style properties cannot distinguish a normally positioned tspan from one at y=-500. Detection requires reading the tspan’s own y attribute, parsing it as a numeric SVG coordinate, and checking whether the value falls within the SVG viewport’s y range [0, svgHeight]. Alternatively, read the tspan’s getBoundingClientRect() and verify it has non-zero overlap with the SVG container BCR.

<!-- SA-CSS-TYO-001: tspan y="-500" — absolute y; 500 SVG units above viewport top (y=0) -->
<!-- SVG origin at top-left; y=-500 is 500px above SVG top edge; overflow:hidden clips -->
<!-- textContent of parent <text> includes consent text (DOM check PASSES) -->
<!-- Parent BCR non-zero (layout); getComputedStyle: visibility=visible, display=inline -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- tspan y="-500": absolute y coordinate; baseline 500px above SVG top edge -->
  <!-- SVG overflow=hidden (default): y<0 clipped; zero tspan pixels rendered -->
  <!-- textContent of parent text element: "Allow: shell execution, credential access" -->
  <text x="20" y="50" font-size="13" fill="#111">
    <tspan y="-500">Allow: 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: check tspan y attribute for out-of-viewport absolute values
function checkTspanYOffset(svgEl) {
  const findings = [];
  const svgBcr    = svgEl.getBoundingClientRect();
  const svgHeight = svgEl.viewBox?.baseVal?.height || svgEl.clientHeight || 120;

  for (const tspan of svgEl.querySelectorAll('tspan')) {
    const yAttr = tspan.getAttribute('y');
    if (yAttr === null) continue; // no y override; inherits cursor position

    // y can be a space/comma-separated list; take the first value
    const firstY = parseFloat(yAttr.trim().split(/[\s,]+/)[0]);
    if (isNaN(firstY)) continue;

    // Check if y value places the tspan outside the SVG viewport
    const isAboveViewport = firstY < 0;        // negative y is above SVG origin
    const isBelowViewport = firstY > svgHeight * 20; // far below viewport

    if (!isAboveViewport && !isBelowViewport) continue;

    // BCR intersection as rendering-level confirmation
    const tBcr = tspan.getBoundingClientRect();
    const overlapX = Math.max(0,
      Math.min(tBcr.right,  svgBcr.right)  - Math.max(tBcr.left, svgBcr.left));
    const overlapY = Math.max(0,
      Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top,  svgBcr.top));

    findings.push({
      severity: 'CRITICAL', code: 'SA-CSS-TYO-001',
      note: `<tspan> y="${yAttr}"; firstY=${firstY}; ` +
            `SVG height=${svgHeight}px; ` +
            (isAboveViewport ? `above viewport by ${Math.abs(firstY)}px (y<0)` : '') +
            (isBelowViewport ? `below viewport by ${firstY - svgHeight}px` : '') +
            `; BCR intersection area=${overlapX * overlapY}px² (0 = fully off-screen); ` +
            `textContent="${tspan.textContent.trim().slice(0,30)}..." intact; ` +
            `getComputedStyle: visibility=visible, display=inline; parent BCR non-zero`
    });
  }
  return findings;
}

CRITICAL — SA-CSS-TYO-001: tspan y="-500" places the consent tspan 500 SVG user units above the viewport origin. In the standard SVG coordinate system (origin top-left), y=-500 is 500px above the SVG’s top edge and is clipped by overflow:hidden. The parent <text> textContent is intact and BCR is non-zero. Detection must read the tspan’s own y attribute as a numeric SVG coordinate; any value with y < 0 or y >> svgHeight is off-screen; BCR intersection with the SVG container at zero area confirms the clip.

Attack 2: tspan y="5000" — consent tspan displaced 5000 units below the SVG viewport (SA-CSS-TYO-002)

The below-viewport variant uses a large positive y value to displace the tspan below the SVG’s visible area. With y="5000" and a 120px viewport height, the tspan baseline is 4880px below the SVG bottom edge. The SVG overflow:hidden default clips content at y=120. The parent <text> element’s layout is computed from its declared y position; the tspan’s displaced y does not necessarily propagate to the parent BCR. textContent is intact. The tspan BCR has BCR.top >> svgBcr.bottom, confirming it is below the SVG. Detection must check both ends: y < 0 (above) and y >> svgHeight (below). The unified signal is the tspan BCR intersection with the SVG container BCR: zero intersection on a non-empty tspan is a reliable off-screen indicator regardless of direction.

<!-- SA-CSS-TYO-002: tspan y="5000" — baseline 5000 SVG units below origin; SVG height=120px -->
<!-- 4880px below SVG bottom edge; overflow=hidden clips; BCR.top >> svgBcr.bottom -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- tspan y="5000": baseline 5000px below origin; SVG height=120px; 4880px past bottom -->
  <!-- overflow=hidden: clipped at y=120; zero tspan pixels rendered within viewport -->
  <text x="20" y="50" font-size="13" fill="#111">
    <tspan y="5000">Allow: 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: tspan BCR.top > svgBcr.bottom signals off-screen below
// Shared with TYO-001 via the general intersection check:
function isTspanOffScreen(tspan, svgBcr) {
  const tBcr  = tspan.getBoundingClientRect();
  const overX = Math.max(0,
    Math.min(tBcr.right,  svgBcr.right)  - Math.max(tBcr.left, svgBcr.left));
  const overY = Math.max(0,
    Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top,  svgBcr.top));
  return overX * overY === 0; // true = zero intersection = off-screen in some direction
}
// Severity: CRITICAL; code: SA-CSS-TYO-002 when y >> svgHeight

CRITICAL — SA-CSS-TYO-002: tspan y="5000" places the tspan baseline 4880px below the bottom of a 120px SVG. overflow:hidden clips the content. A scanner checking only y < 0 as the off-screen signal misses this variant. Detection must check both ends: y < 0 (above) and y >> svgHeight (below), or use the unified BCR intersection test on the tspan (zero intersection with the SVG container = off-screen in any direction).

Attack 3: per-tspan scope — header tspan renders at y="30", consent tspan has y="-500" (SA-CSS-TYO-003)

This variant places both the header text and the consent text inside a single parent <text> element as separate <tspan> children. The header tspan has y="30", rendering it 30 SVG units from the top — well within the 120px viewport height. The consent tspan has y="-500", placing its baseline 500px above the viewport. The parent <text> element demonstrates that the SVG renders text correctly: the header is visible and legible. A scanner inspecting the parent finds non-empty textContent, a non-zero BCR, and a visibly rendering element. The any-visible-tspan heuristic finds the header tspan, passes a legibility check, and treats the parent as fully visible.

The correct detection approach requires iterating each <tspan> descendant of each consent-bearing <text> element independently. For each tspan that carries consent-relevant content (permission scope text, action descriptions), the scanner must independently read the tspan’s own y attribute and verify the absolute coordinate is within the SVG viewport height range. The tspan’s BCR intersection check provides a rendering-level confirmation. The presence of a visible sibling tspan does not affect the result.

<!-- SA-CSS-TYO-003: per-tspan — header tspan y="30" (visible); consent tspan y="-500" (off-screen) -->
<!-- Both tspans inside the same parent <text>; parent BCR reflects header tspan -->
<!-- Any-visible-tspan check PASSES (header tspan visible at y=30) -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="140" fill="white"/>

  <!-- header tspan y="30": within viewport; 13px font; renders normally -->
  <!-- consent tspan y="-500": absolute y=-500; 500px above SVG top; clipped -->
  <text x="20" font-size="13" fill="#111">
    <tspan y="30">Permission Request:</tspan>
    <tspan y="-500">Allow: shell execution, credential access</tspan>
  </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>
  <rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

// Detection: per-tspan y-attribute check inside consent <text> elements
function checkPerTspanYOffset(svgEl) {
  const findings = [];
  const svgBcr    = svgEl.getBoundingClientRect();
  const svgHeight = svgEl.viewBox?.baseVal?.height || svgEl.clientHeight || 140;

  for (const tspan of svgEl.querySelectorAll('text tspan')) {
    const yAttr = tspan.getAttribute('y');
    if (yAttr === null) continue;

    const firstY = parseFloat(yAttr.trim().split(/[\s,]+/)[0]);
    if (isNaN(firstY)) continue;

    const isAbove = firstY < 0;
    const isBelow = firstY > svgHeight * 20;
    if (!isAbove && !isBelow) continue;

    const tBcr = tspan.getBoundingClientRect();
    const overX = Math.max(0,
      Math.min(tBcr.right,  svgBcr.right)  - Math.max(tBcr.left, svgBcr.left));
    const overY = Math.max(0,
      Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top,  svgBcr.top));

    findings.push({
      severity: 'HIGH', code: 'SA-CSS-TYO-003',
      note: `<tspan> y="${firstY}" (off-screen ${isAbove ? 'ABOVE' : 'BELOW'}); ` +
            `SVG height=${svgHeight}px; ` +
            `sibling tspan(s) have in-viewport y and render visibly; ` +
            `parent <text> BCR non-zero (sibling tspan contributes); ` +
            `BCR intersection area=${overX * overY}px² (0 = fully clipped); ` +
            `any-visible-tspan check PASSES (header tspan at y=30 visible); ` +
            `per-tspan y-attribute inspection required; ` +
            `textContent="${tspan.textContent.trim().slice(0,30)}..."`
    });
  }
  return findings;
}

HIGH — SA-CSS-TYO-003: The consent <tspan> has y="-500" while the sibling header <tspan> renders at y="30". The parent <text> BCR is non-zero; any-visible-tspan check passes on the header. Each consent-bearing tspan must be independently inspected: read the tspan’s y attribute and check it against the SVG viewport height, or compute the tspan BCR intersection with the SVG container (zero = off-screen).

Attack 4: JS mousedown injects y="-500" on consent tspan, reverts at mouseup (SA-CSS-TYO-004)

The dynamic form of the tspan-y attack sets the tspan’s y attribute to "-500" during a mouse press. At rest, the consent tspan has a normal in-viewport y value (or no y override, rendering at the parent’s declared y position). A mousedown event listener on the SVG container (registered in the capture phase) calls tspan.setAttribute(‘y’, ‘-500’). The tspan baseline jumps to y=-500, which is 500px above the SVG top edge and clipped by overflow:hidden. The rendering engine repaints immediately; the consent text is absent from the viewport during the click window. At mouseup, the attribute is restored.

A static scan at rest finds the tspan at a normal y position, passing all checks. A MutationObserver watching the tspan’s y attribute catches the mutation and can verify the new value is off-screen. The observer must be configured to watch individual tspan elements (not just the parent SVG) to receive attribute mutation events for the y attribute. Because y is a presentational attribute, it is not captured by CSS property watchers or by observers filtering only for style attribute changes; the observer must explicitly include ‘y’ in its attributeFilter.

<!-- SA-CSS-TYO-004: at rest tspan has y="50" (visible); mousedown injects y="-500"; -->
<!-- tspan jumps above viewport; clipped; reverts to y="50" at mouseup -->
<!-- static scan finds y="50" (in viewport); static checks PASS -->
<svg id="consent-svg-tyo" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- at rest: tspan y="50"; within 120px viewport; renders normally -->
  <!-- mousedown: setAttribute('y', '-500'); tspan jumps above SVG; clipped -->
  <text x="20" font-size="13" fill="#111">
    <tspan id="consent-tspan-tyo" y="50">Allow: 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>

<script>
(function() {
  const svg   = document.getElementById('consent-svg-tyo');
  const tspan = document.getElementById('consent-tspan-tyo');
  svg.addEventListener('mousedown', () => {
    tspan.setAttribute('y', '-500'); // jumps tspan baseline 500px above SVG top
  }, true); // capture phase
  svg.addEventListener('mouseup', () => {
    tspan.setAttribute('y', '50');  // restore normal position
  }, true);
})();
</script>

// Detection: MutationObserver on y attribute of tspan elements + BCR verification
function watchTspanYInject(svgEl) {
  const findings = [];
  const svgBcr    = svgEl.getBoundingClientRect();
  const svgHeight = svgEl.viewBox?.baseVal?.height || svgEl.clientHeight || 120;

  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      if (mut.attributeName !== 'y') continue;
      const tspan = mut.target;
      if (tspan.tagName !== 'tspan') continue;

      const newY = parseFloat(tspan.getAttribute('y') || '0');
      const isAbove = newY < 0;
      const isBelow = newY > svgHeight * 20;
      if (!isAbove && !isBelow) continue;

      const tBcr  = tspan.getBoundingClientRect();
      const overX = Math.max(0,
        Math.min(tBcr.right,  svgBcr.right)  - Math.max(tBcr.left, svgBcr.left));
      const overY = Math.max(0,
        Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top,  svgBcr.top));

      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-TYO-004',
        note: `Dynamic y="${newY}" injected on <tspan> via setAttribute during interaction; ` +
              `${isAbove ? `above viewport by ${Math.abs(newY)}px` : `below viewport by ${newY - svgHeight}px`}; ` +
              `BCR intersection area=${overX * overY}px² after inject (0 = off-screen confirmed); ` +
              `at rest: tspan y="50" (in viewport; static checks PASS); ` +
              `MutationObserver attributeFilter=['y'] on tspan elements required; ` +
              `y is a presentational attr — style-attr observer MISSES this variant; ` +
              `synthetic mousedown triggers inject; BCR=0 confirms; ` +
              `textContent="${tspan.textContent?.trim().slice(0,30) ?? ''}..." intact`
      });
    }
  });

  for (const tspan of svgEl.querySelectorAll('tspan')) {
    observer.observe(tspan, { attributes: true, attributeFilter: ['y'] });
  }

  // Synthesize interaction
  svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
  svgEl.dispatchEvent(new MouseEvent('mouseup',   { bubbles: true, cancelable: true }));

  return findings;
}

CRITICAL — SA-CSS-TYO-004: JavaScript injects y="-500" on the consent <tspan> at mousedown, jumping the baseline 500px above the viewport and clipping the text. At rest, the tspan is in the viewport and all static checks pass. Detection requires a MutationObserver with attributeFilter: [‘y’] watching consent tspan elements — y is a presentational attribute and is not captured by style-attribute observers. Combine with synthetic mousedown dispatch and post-mutation BCR intersection check (zero overlap confirms off-screen).

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-TYO-001: tspan y="-500" — above viewport tspan y=-500 places baseline 500px above SVG top edge (y=0 is top-left); overflow:hidden clips y<0 content; parent text BCR non-zero; textContent intact; getComputedStyle shows normal values textContent; parent BCR; getComputedStyle; requires: parse tspan y attribute as SVG coordinate; check y < 0 (above) or y >> svgHeight (below); or BCR intersection test Critical
SA-CSS-TYO-002: tspan y="5000" — below viewport y=5000 places baseline 4880px below bottom of 120px SVG; overflow:hidden clips; BCR.top >> svgBcr.bottom; textContent intact textContent; parent BCR; y<0-only scanner; requires: check y >> svgHeight; or unified BCR intersection test (zero overlap regardless of direction) Critical
SA-CSS-TYO-003: per-tspan scope — header tspan y="30" (visible); consent tspan y="-500" Header tspan y="30" (within viewport); consent tspan y="-500" (above; clipped); parent BCR non-zero; any-visible-tspan check passes on header textContent; any-visible-tspan check; parent BCR; requires: per-tspan y-attribute check for each consent tspan independently High
SA-CSS-TYO-004: mousedown injects y="-500"; reverts mouseup At rest: tspan y="50" (in viewport; normal); static checks pass. mousedown: y="-500"; above viewport; clipped; BCR intersection=0. mouseup: y="50" restored Static DOM; requires: MutationObserver attributeFilter=['y'] on tspan (y is presentational attr; style observer misses) + synthetic mousedown + BCR intersection check after mutation Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-TYO-001: SVG tspan y="-500" on consent tspan element — the tspan y attribute sets the absolute SVG y-coordinate of the tspan baseline; y=-500 places the baseline 500 SVG user units above the coordinate origin; in the standard SVG coordinate system (origin top-left, y increasing downward), y=-500 is 500px above the SVG element’s top edge; SVG overflow:hidden (default per specification) clips all content at y<0 to zero rendered pixels; the parent <text> element’s textContent property returns the full consent text string (DOM check passes); parent BCR is non-zero; getComputedStyle shows visibility=visible, display=inline, no opacity or clip markers; detection must read the tspan’s own y attribute as a numeric SVG coordinate and check y < 0 (above viewport) or y >> svgHeight (below viewport); BCR intersection of the tspan with the SVG container at zero area provides a rendering-level confirmation.
CRITICAL SA-CSS-TYO-002: SVG tspan y="5000" on consent tspan element — y=5000 places the tspan baseline 4880px below the bottom edge of a 120px SVG; overflow:hidden clips content at y > svgHeight; the tspan BCR.top is far below svgBcr.bottom; textContent intact; DOM membership passes; a scanner checking only y < 0 as the off-screen signal misses this variant; correct detection checks both ends (y < 0 = above; y >> svgHeight = below) or uses a unified BCR intersection test: if the tspan BCR has zero overlap with the SVG container BCR and the tspan has non-empty textContent, the tspan is off-screen regardless of direction.
HIGH SA-CSS-TYO-003: per-tspan scope — header tspan has y="30" (within 120px viewport; renders normally at 13px); consent tspan has y="-500" (500px above viewport; clipped); both tspans are children of the same parent <text> element; parent BCR is non-zero because the header tspan contributes to the layout; any-visible-tspan heuristic finds the header tspan and produces a false negative for the consent tspan; per-tspan iteration is required: for each tspan within a consent-bearing <text> element, independently read the tspan’s y attribute and check it against the viewport range [0, svgHeight], or compute the tspan BCR intersection with the SVG container.
CRITICAL SA-CSS-TYO-004: JS mousedown injects tspan y="-500" on consent tspan during click initiation; tspan baseline jumps 500px above SVG top edge; rendering engine clips the tspan to zero pixels; at mouseup y attribute is restored to "50"; at rest the tspan renders normally at y=50 and all static checks pass; requires MutationObserver with attributeFilter [‘y’] observing consent tspan elements — the y attribute is a presentational attribute and is NOT captured by a style-attribute MutationObserver or by CSS property watchers; synthetic mousedown dispatch triggers the injection; post-mutation BCR intersection area check (zero = off-screen confirmed) provides rendering-level verification independent of attribute parsing.

Related: SVG tspan x offset consent attacks  |  SVG transform consent attacks  |  SVG viewBox consent attacks

← Blog  |  Security Checklist