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
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| 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 |