Security Guide

MCP server SVG tspan dy offset consent security — dy="-5000" adds a relative −5000-unit vertical displacement above the viewport, dy="5000" moves consent text far below the viewport, per-tspan vertical scope hides only the consent portion, and mousedown injects the offset during the click window

The SVG <tspan> element’s dy attribute adds a relative vertical displacement to the current text cursor y-position. Unlike the y attribute, which overrides the baseline with an absolute coordinate in SVG user-unit space, dy is cumulative: the effective y-position of the tspan’s baseline equals the cursor’s current y-position plus the dy value. Setting dy="-5000" on a tspan element moves the tspan 5000 SVG user units above wherever the cursor currently is. For a parent <text> element at y="50" in a 120px-tall viewport, the effective baseline is 50 + (−5000) = −4950 SVG user units — 4950px above the SVG’s top edge. SVG’s default overflow="hidden" clips all content outside the viewport boundary. The consent text is absent from all rendered pixels, but its textContent is intact, the parent <text> element’s BCR is non-zero, and getComputedStyle reports no hidden, opacity, or clipping markers. Four attack patterns: dy="-5000" above viewport, dy="5000" below viewport, per-tspan vertical scope, and mousedown injection.

Attack 1: tspan dy="-5000" — consent tspan displaced 5000 units above the viewport via relative vertical offset (SA-CSS-TDY-001)

The dy attribute on a <tspan> element specifies a relative vertical shift from the current text cursor y-position. A single value applies to the first character; subsequent characters continue the normal line progression from the shifted baseline. Setting dy="-5000" moves the tspan 5000 SVG user units above the cursor’s current y. In SVG, the y-axis points downward: y=0 is the top of the coordinate system, and increasing y moves down. A negative dy therefore moves the tspan upward. For a parent <text> element at y="50", the cursor before this tspan is at y=50. The dy="-5000" offset moves it to y = 50 − 5000 = −4950 — 4950px above the SVG top edge. SVG’s default overflow:hidden clips content at the viewport’s top boundary (y=0), so zero pixels of the tspan are rendered.

This attack is distinct from the y attribute variant (SA-CSS-TYO). A scanner checking tspan elements for a large negative absolute y value will find nothing: there is no y attribute on the tspan. The dy attribute must be independently parsed and added to the cursor’s running y-position to determine the effective baseline. The parent text element’s textContent includes the full consent string. The BCR of the parent text element is non-zero because the element’s layout box reflects its declared position. getComputedStyle(tspan) shows normal display, visibility, and opacity values. Only a dy-aware check computing the effective y reveals the off-screen displacement.

<!-- SA-CSS-TDY-001: tspan dy="-5000" — relative vertical offset; cursor moves 5000 units above -->
<!-- Parent text y=50; cursor at y=50 before tspan; dy="-5000" → effective y = 50-5000 = -4950 -->
<!-- SVG overflow=hidden (default): y<0 content clipped; entire tspan above viewport -->
<!-- textContent of parent <text> includes consent text; parent BCR non-zero -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- dy="-5000": relative; cursor at y=50; effective y = 50+(-5000) = -4950 -->
  <!-- overflow=hidden clips y<0; tspan renders zero pixels within viewport -->
  <!-- no y attribute on tspan: y-attribute scanner finds nothing -->
  <text x="20" y="50" font-size="13" fill="#111">
    <tspan dy="-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: compute cursor y and check effective position after dy
function checkTspanDyOffset(svgEl) {
  const findings = [];
  const svgBcr = svgEl.getBoundingClientRect();
  const svgHeight = svgEl.viewBox?.baseVal?.height || svgEl.clientHeight || 120;

  for (const textEl of svgEl.querySelectorAll('text')) {
    // Starting cursor y = parent text y attribute (or 0 if absent)
    let cursorY = parseFloat(textEl.getAttribute('y') || '0') || 0;

    for (const tspan of textEl.querySelectorAll('tspan')) {
      // y attribute resets cursor to absolute y position
      const yAttr = tspan.getAttribute('y');
      if (yAttr !== null) {
        cursorY = parseFloat(yAttr.trim().split(/[\s,]+/)[0]) || cursorY;
      }

      // dy adds relative vertical offset
      const dyAttr = tspan.getAttribute('dy');
      if (dyAttr !== null) {
        const firstDy = parseFloat(dyAttr.trim().split(/[\s,]+/)[0]);
        if (!isNaN(firstDy)) {
          const effectiveY = cursorY + firstDy;
          const isAbove = effectiveY < -(svgHeight * 2);
          const isBelow = effectiveY > svgHeight * 10;

          if (isAbove || isBelow) {
            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-TDY-001',
              note: `<tspan> dy="${dyAttr}"; cursorY before tspan=${cursorY}; ` +
                    `effectiveY=${effectiveY}; SVG height=${svgHeight}px; ` +
                    (isAbove ? `ABOVE viewport by ${Math.abs(effectiveY)}px` : '') +
                    (isBelow ? `BELOW viewport by ${effectiveY - svgHeight}px` : '') +
                    `; BCR intersection area=${overlapX * overlapY}px² (0=off-screen); ` +
                    `no tspan y attr (y-only scanner MISSES); ` +
                    `textContent="${tspan.textContent.trim().slice(0,30)}..." intact`
            });
          }
          cursorY = effectiveY;
        }
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-TDY-001: tspan dy="-5000" adds a relative −5000 vertical offset to the current cursor y-position. For a parent at y=50, the effective tspan baseline is −4950 — 4950px above the SVG top edge in a 120px viewport. SVG overflow:hidden clips the content above y=0. The tspan has no y attribute; a y-attribute scanner finds nothing. Detection must parse dy, track the cursor’s running y-position, compute the effective y, and check if it falls outside the SVG viewport vertically.

Attack 2: tspan dy="5000" — consent tspan displaced 5000 units below the viewport via relative vertical offset (SA-CSS-TDY-002)

The positive-dy variant uses a large positive dy value to displace the tspan far below the SVG viewport. With the cursor at y=50 (parent y="50") and dy="5000", the effective baseline is 50 + 5000 = 5050 SVG user units — 4930px below the bottom of a 120px-tall viewport. SVG overflow:hidden clips content at the lower viewport boundary (y=120). The tspan is geometrically off-screen, textContent is intact, parent BCR is non-zero, and all per-element style checks pass. A scanner checking only for large negative dy values misses this variant. The check must handle both directions: large negative dy (above viewport) and large positive dy (below viewport).

<!-- SA-CSS-TDY-002: tspan dy="5000" — cursor at y=50; effective y = 50+5000 = 5050; below viewport -->
<!-- SVG height=120px; 5050-120 = 4930px beyond bottom edge; overflow=hidden clips -->
<!-- BCR.top >> viewport height; tspan BCR has zero intersection with SVG container BCR -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- dy="5000": relative; cursor at y=50; effective y = 5050 -->
  <!-- 5050 - 120 = 4930px below bottom; overflow=hidden clips at y=120 -->
  <text x="20" y="50" font-size="13" fill="#111">
    <tspan dy="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: unified BCR intersection check — works for both above and below
function checkTspanDyOffBelow(tspan, cursorY, svgBcr, svgHeight) {
  const dyAttr = tspan.getAttribute('dy');
  if (dyAttr === null) return null;
  const firstDy = parseFloat(dyAttr.trim().split(/[\s,]+/)[0]);
  if (isNaN(firstDy)) return null;

  const effectiveY = cursorY + firstDy;
  // Not meaningfully off-screen; skip
  if (effectiveY > -(svgHeight * 2) && effectiveY < svgHeight * 10) return null;

  const tBcr = tspan.getBoundingClientRect();
  const intersectionY = Math.max(0,
    Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top, svgBcr.top));

  return {
    severity: 'CRITICAL', code: 'SA-CSS-TDY-002',
    note: `<tspan> dy="${firstDy}"; cursorY=${cursorY}; effectiveY=${effectiveY}; ` +
          `SVG height=${svgHeight}px; ` +
          `y-intersection=${intersectionY}px (0=off-screen); ` +
          `BCR.top=${tBcr.top.toFixed(0)}, svgBcr.bottom=${svgBcr.bottom.toFixed(0)}; ` +
          `textContent="${tspan.textContent.trim().slice(0,30)}..." intact`
  };
}

CRITICAL — SA-CSS-TDY-002: tspan dy="5000" with cursor at y=50 produces effective y=5050, which is 4930px below the bottom of a 120px-tall viewport. A scanner checking only for negative dy values misses this variant. Detection must check the effective y-position in both directions against the SVG viewport height; alternatively, use the direction-agnostic BCR intersection test (zero y-overlap with SVG container = off-screen).

Attack 3: per-tspan vertical scope — header tspan at normal cursor y, consent tspan dy="-5000" (SA-CSS-TDY-003)

The per-tspan vertical scope attack uses two <tspan> children inside the same <text> element. The header tspan has an explicit y attribute (or no vertical override), rendering at its normal baseline — fully visible. After the header tspan, the cursor advances to the header’s y-position plus any normal line height. The consent tspan then applies dy="-5000", moving 5000 units above the cursor’s current y — which includes the header’s line-height advance. The result is a consent tspan far above the viewport. The parent BCR is non-zero (header contributes), textContent has both strings, and a visible-tspan heuristic passes because the header renders normally.

<!-- SA-CSS-TDY-003: per-tspan vertical scope -->
<!-- header tspan y=30 (explicit; resets cursor to y=30; visible at 13px) -->
<!-- consent tspan dy="-5000" from cursor (≈y=30 after header); effective y ≈ 30-5000 = -4970 -->
<!-- parent BCR non-zero (header contributes); any-visible-tspan check PASSES -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="140" fill="white"/>

  <!-- header tspan: explicit y=30; cursor reset to y=30; renders at y=30; visible -->
  <!-- after header, cursor still ≈ y=30 (tspan doesn't advance line height by default) -->
  <!-- consent tspan: dy="-5000"; effective y = 30+(-5000) = -4970; off-screen above -->
  <text x="20" font-size="13" fill="#111">
    <tspan y="30">Permission Request:</tspan>
    <tspan y="50" dy="-5000">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 cursor-tracking loop (y axis version)
function checkPerTspanDyOffset(svgEl) {
  const findings = [];
  const svgBcr   = svgEl.getBoundingClientRect();
  const svgHeight = svgEl.viewBox?.baseVal?.height || svgEl.clientHeight || 120;

  for (const textEl of svgEl.querySelectorAll('text')) {
    let cursorY = parseFloat(textEl.getAttribute('y') || '0') || 0;

    for (const tspan of textEl.querySelectorAll('tspan')) {
      const yAttr = tspan.getAttribute('y');
      if (yAttr !== null) {
        cursorY = parseFloat(yAttr.trim().split(/[\s,]+/)[0]) || cursorY;
      }

      const dyAttr = tspan.getAttribute('dy');
      if (dyAttr !== null) {
        const firstDy = parseFloat(dyAttr.trim().split(/[\s,]+/)[0]);
        if (!isNaN(firstDy)) {
          const effectiveY = cursorY + firstDy;
          const isAbove = effectiveY < -(svgHeight * 2);
          const isBelow = effectiveY > svgHeight * 10;

          if (isAbove || isBelow) {
            const tBcr  = tspan.getBoundingClientRect();
            const overlapArea =
              Math.max(0, Math.min(tBcr.right,  svgBcr.right)  - Math.max(tBcr.left, svgBcr.left)) *
              Math.max(0, Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top,  svgBcr.top));

            findings.push({
              severity: 'HIGH', code: 'SA-CSS-TDY-003',
              note: `per-tspan dy: cursorY before tspan ≈ ${cursorY}; dy="${firstDy}"; ` +
                    `effectiveY=${effectiveY} (${isAbove ? 'ABOVE viewport' : 'BELOW viewport'}); ` +
                    `sibling tspan(s) rendered visibly; parent BCR non-zero; ` +
                    `any-visible-tspan check PASSES; BCR overlap=${overlapArea}px²; ` +
                    `textContent="${tspan.textContent.trim().slice(0,30)}..." intact`
            });
          }
          cursorY = effectiveY;
        }
      }
    }
  }
  return findings;
}

HIGH — SA-CSS-TDY-003: The consent <tspan> has dy="-5000"; the cursor y-position when that tspan is processed is approximately y=50 (set by an explicit y attribute on the consent tspan, combined with dy="-5000", giving effective y = −4950). The header tspan renders at y=30 and makes the parent BCR non-zero; a visible-tspan check passes. Detection requires cursor-tracking over all tspan children, evaluating each dy relative to the running cursor y, and checking the effective baseline against the SVG viewport height.

Attack 4: JS mousedown injects dy="-5000" on consent tspan, reverts at mouseup (SA-CSS-TDY-004)

The dynamic form sets dy="-5000" on the consent tspan exactly during a mouse press. At rest, the tspan has no dy attribute, rendering at its cursor-inherited or explicitly set y-position. A mousedown listener (capture phase) calls tspan.setAttribute(‘dy’, ‘-5000’). The rendering engine repositions the tspan to y = cursor_y − 5000 before any bubble-phase click handler fires. At mouseup, the attribute is removed and the tspan returns to its normal y. The consent text is invisible during the click window; static inspection at rest finds no dy attribute.

Detection requires a MutationObserver on the tspan’s dy attribute. Because dy is a presentation attribute — not a CSS property — MutationObserver with attributeFilter: [‘dy’] is the correct mechanism; style-attribute observation alone does not catch direct SVG attribute changes. A synthetic mousedown dispatch triggers the injection; post-mutation BCR intersection check confirms the off-screen displacement.

<!-- SA-CSS-TDY-004: at rest no dy (tspan at cursor-inherited or set y; renders normally) -->
<!-- mousedown: setAttribute('dy', '-5000'); effective y = cursor_y-5000; off-screen above -->
<!-- mouseup: removeAttribute('dy'); tspan returns to normal baseline -->
<svg id="consent-svg-tdy" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- at rest: no dy attr; tspan at y=50 (inherits parent y); renders normally -->
  <!-- mousedown: dy="-5000"; effective y = 50-5000 = -4950; off-screen above -->
  <text x="20" y="50" font-size="13" fill="#111">
    <tspan id="consent-tspan-tdy">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-tdy');
  const tspan = document.getElementById('consent-tspan-tdy');
  svg.addEventListener('mousedown', () => {
    tspan.setAttribute('dy', '-5000'); // relative; effective y = cursor_y - 5000; off-screen
  }, true);
  svg.addEventListener('mouseup', () => {
    tspan.removeAttribute('dy');
  }, true);
})();
</script>

// Detection: MutationObserver on dy attribute + BCR verification
function watchTspanDyInject(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 !== 'dy') continue;
      const tspan = mut.target;
      if (tspan.tagName !== 'tspan') continue;

      const dyAttr = tspan.getAttribute('dy');
      if (dyAttr === null) continue;
      const firstDy = parseFloat(dyAttr.trim().split(/[\s,]+/)[0]);
      if (isNaN(firstDy) || Math.abs(firstDy) < svgHeight * 5) continue;

      const tBcr = tspan.getBoundingClientRect();
      const intersectionY = Math.max(0,
        Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top, svgBcr.top));

      if (intersectionY === 0) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-TDY-004',
          note: `Dynamic dy="${firstDy}" injected on <tspan> via setAttribute during interaction; ` +
                `SVG height=${svgHeight}px; |dy|=${Math.abs(firstDy)} >> viewport; ` +
                `BCR y-intersection=0 after inject (off-screen confirmed); ` +
                `at rest: no dy attr (cursor-inherited y; normal BCR); static checks PASS; ` +
                `MutationObserver attributeFilter=['dy'] subtree=true required; ` +
                `synthetic mousedown triggers inject; dy is SVG presentation attr; ` +
                `style observer alone MISSES; ` +
                `textContent="${tspan.textContent?.trim().slice(0,30) ?? ''}..." intact`
        });
      }
    }
  });

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

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

  return findings;
}

CRITICAL — SA-CSS-TDY-004: JavaScript injects dy="-5000" on the consent <tspan> at mousedown. dy is a SVG presentation attribute — not a CSS property — so a style-attribute observer alone does not catch it. Detection requires a MutationObserver with attributeFilter: [‘dy’], a synthetic mousedown dispatch to trigger the injection, and a post-mutation BCR intersection check (zero y-overlap with SVG container = off-screen confirmed).

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-TDY-001: tspan dy="-5000" — above viewport dy adds relative -5000 offset to cursor y (cursor at y=50); effective y=-4950; 4950px above 120px viewport; overflow:hidden clips; parent BCR non-zero; textContent intact textContent; parent BCR; getComputedStyle; tspan y-attribute scanner (no y attr); requires: parse dy, track cursor y, compute effective y, check vs viewport height Critical
SA-CSS-TDY-002: tspan dy="5000" — below viewport dy=5000 at cursor y=50; effective y=5050; 4930px past bottom of 120px SVG; overflow:hidden clips; tspan BCR zero y-intersection with SVG container textContent; parent BCR; negative-dy-only scanner; requires: check effective y >> svgHeight; or unified BCR y-intersection test Critical
SA-CSS-TDY-003: per-tspan vertical scope — header visible; consent tspan dy="-5000" Header tspan at y=30 (visible); consent tspan y=50 dy="-5000" → effective y=-4950; above viewport; parent BCR non-zero (header); any-visible-tspan check passes textContent; any-visible-tspan check; parent BCR; requires: cursor-tracking loop over tspan children, evaluate dy relative to running cursor y High
SA-CSS-TDY-004: mousedown injects dy="-5000"; reverts mouseup At rest: no dy; cursor-inherited y; BCR normal; static checks pass. mousedown: dy="-5000"; effective y off-screen; BCR y-intersection=0. mouseup: dy removed Static DOM; style-attribute observer alone (dy is SVG presentation attribute); requires: MutationObserver attributeFilter=['dy'] + synthetic mousedown + BCR y-intersection check Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-TDY-001: SVG tspan dy="-5000" on consent tspan — dy is a relative vertical displacement added to the current text cursor y-position; the cursor before this tspan is at y=50 (parent text y attribute); effective baseline = 50 + (-5000) = -4950 SVG user units; in a 120px-tall viewport with 1:1 user-unit-to-pixel mapping the tspan is 4950px above the SVG top edge; SVG overflow:hidden clips content outside the viewport boundary; textContent of parent text returns full consent string; parent BCR is non-zero; getComputedStyle shows no opacity:0, visibility:hidden, display:none, clip-path; the tspan has no y attribute — a y-attribute scanner finds nothing; detection must parse the dy attribute, track the running cursor y across tspan siblings, compute effective y = cursor_y + dy, and check whether the effective position falls outside the SVG viewport vertically.
CRITICAL SA-CSS-TDY-002: SVG tspan dy="5000" on consent tspan — dy=5000 at cursor y=50 produces effective y=5050, which is 4930px below the bottom of a 120px-tall viewport; SVG overflow:hidden clips at the bottom boundary; the tspan BCR.top is far past viewport bottom; textContent intact; parent BCR non-zero; a scanner checking only for large negative dy values misses this variant; detection must check effective y in both directions — effectiveY < -(svgHeight × 2) (above) AND effectiveY > svgHeight × 10 (below); the direction-agnostic approach computes BCR y-intersection area between tspan and SVG container and flags zero-area results on consent tspans.
HIGH SA-CSS-TDY-003: per-tspan vertical scope — header tspan renders at y=30 (visible at 13px); consent tspan has explicit y=50 combined with dy="-5000", giving effective y = 50 + (-5000) = -4950; both tspans are children of the same parent text element; parent BCR is non-zero because the header tspan contributes layout; any-visible-tspan check passes because the header tspan is visible at y=30; per-tspan cursor-tracking is required: initialize cursor y from parent text y, update cursor for each y attribute reset, add dy values to running cursor, compute effective y for each tspan independently, check each against viewport bounds.
CRITICAL SA-CSS-TDY-004: JS mousedown injects dy="-5000" on consent tspan during click initiation; dy is a SVG presentation attribute — not a CSS property — so MutationObserver on the style attribute alone does not detect the change; attributeFilter: ['dy'] is required; synthetic mousedown dispatch triggers the injection; post-mutation BCR y-intersection check (zero y-overlap with SVG container = off-screen above or below confirmed); at rest no dy attribute — static scan and style scan both pass; note: both dx and dy injection attacks must be watched simultaneously since an attacker may combine them to displace text diagonally off-screen in one mutation.

Related: SVG tspan y absolute offset consent attacks  |  SVG tspan dx horizontal offset consent attacks  |  SVG transform consent attacks

← Blog  |  Security Checklist