Security Guide
MCP server SVG tspan x offset consent security — x="-5000" places consent text 5000 SVG units off-screen left, x="5000" displaces off-screen right, per-tspan scope hides only the consent portion while the header renders normally, and mousedown injects the offset during the click window
The SVG <tspan> element’s x attribute sets an absolute horizontal coordinate for the tspan’s characters in the current SVG user-unit coordinate system. Unlike the relative dx attribute, which offsets from the current text cursor position, x overrides the cursor entirely and places characters at the specified absolute coordinate. Setting x="-5000" positions the tspan 5000 SVG user units to the left of the coordinate origin — in a 440px viewport at the default 1:1 user-unit-to-pixel mapping, the text is 5000px off the left edge. SVG’s default overflow="hidden" clips all content outside the viewport boundary, so the consent text is not rendered. The parent <text> element remains in the DOM with intact textContent, a non-zero bounding box, and normal display and visibility. Scanners that inspect the parent element’s properties pass the check while missing the tspan offset. Four attack patterns: x="-5000" off-screen left, x="5000" off-screen right, per-tspan scope, and mousedown injection.
Attack 1: tspan x="-5000" — consent tspan displaced 5000 SVG units off-screen left, clipped by overflow:hidden (SA-CSS-TXO-001)
The x attribute on a <tspan> element specifies the absolute x-coordinate of the first character of the tspan in SVG user-unit space. It is a list-valued attribute: a single value applies to the first character, and the cursor continues from there for subsequent characters. Setting x="-5000" places the first character’s left edge at x=-5000 in the SVG coordinate system. The remaining characters follow the normal advance-width accumulation from that starting point, placing the entire tspan at x < -4900 for typical text strings. In an SVG with width="440" and no custom viewBox, the coordinate system maps 1:1 to CSS pixels, so x=-5000 is 5000px to the left of the SVG’s left edge. The SVG specification defines the default overflow value on an <svg> element as hidden, which clips all rendered content to the viewport boundary. The tspan’s pixels are entirely outside the [0, 440] x-range and are not composited.
The attack leaves multiple surface-level checks passing. The parent <text> element has a non-zero getBoundingClientRect() because the element’s layout box reflects its declared x and y position and an estimated width — the tspan’s actual rendered region (off-screen) is factored in differently by different rendering engines, but the parent text BCR is typically non-zero. The textContent property of the parent <text> element returns the full concatenation of all tspan text, including the displaced tspan content — the permission scope text is intact in the DOM tree. getComputedStyle(tspan).display returns inline; there is no visibility:hidden, no opacity:0, no clip-path. A scanner inspecting the parent text element’s properties passes all checks. Detection requires inspecting each <tspan> element’s own x attribute, parsing it as a numeric SVG coordinate, and verifying that the tspan’s rendered position falls within the SVG viewport. The tspan BCR or a coordinate-space check of the x attribute value is required.
<!-- SA-CSS-TXO-001: tspan x="-5000" — absolute x displaces consent tspan 5000 units off-screen left -->
<!-- Parent text x=20 y=50; tspan x="-5000" overrides cursor position absolutely -->
<!-- SVG overflow=hidden (default): x<0 content clipped; zero consent pixels rendered -->
<!-- textContent of parent <text> includes consent text (DOM check PASSES) -->
<!-- Parent BCR non-zero (layout, not rendering); getComputedStyle shows normal values -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- tspan x="-5000": absolute x coordinate; 5000px left of SVG left edge -->
<!-- SVG overflow=hidden clips at x=0; entire tspan at x<-4900; zero pixels rendered -->
<!-- textContent of parent text = "Allow: shell execution, credential access" (full string) -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan x="-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: check tspan x attribute for large negative values
function checkTspanXOffset(svgEl) {
const findings = [];
const svgBcr = svgEl.getBoundingClientRect();
// Threshold: x value that would place text off the left side of the SVG
// Conservatively: |x| > svgEl.viewBox.baseVal.width * 2 (or viewport width * 2 if no viewBox)
const svgWidth = svgEl.viewBox?.baseVal?.width || svgEl.clientWidth || 440;
for (const tspan of svgEl.querySelectorAll('tspan')) {
const xAttr = tspan.getAttribute('x');
if (xAttr === null) continue; // no x override
// x can be a space/comma-separated list; take the first value
const firstX = parseFloat(xAttr.trim().split(/[\s,]+/)[0]);
if (isNaN(firstX)) continue;
// Check if first x value is far off the left or right edge
const isOffLeft = firstX < -(svgWidth * 2);
const isOffRight = firstX > svgWidth * 10;
if (isOffLeft || isOffRight) {
// Also verify via BCR intersection with SVG container
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-TXO-001',
note: `<tspan> x="${xAttr}"; firstX=${firstX}; ` +
`SVG width=${svgWidth}px; ` +
(isOffLeft ? `off-screen LEFT by ${Math.abs(firstX)}px` : '') +
(isOffRight ? `off-screen RIGHT by ${firstX - svgWidth}px` : '') +
`; BCR intersection area=${overlapX * overlapY}px² (0 = fully off-screen); ` +
`textContent="${tspan.textContent.trim().slice(0,30)}..." intact; ` +
`parent text BCR non-zero; display=inline; getComputedStyle shows no hidden/clipped values; ` +
`SVG overflow=hidden (default) clips at viewport boundary`
});
}
}
return findings;
}
CRITICAL — SA-CSS-TXO-001: tspan x="-5000" places the consent tspan 5000 SVG user units to the left of the coordinate origin. In a 440px viewport, the text is 5000px off the left edge and entirely clipped by SVG’s default overflow:hidden. The parent <text> element’s textContent is intact and its BCR is non-zero. Detection must inspect the x attribute of each <tspan> element directly and check if the absolute value exceeds the SVG viewport dimensions; alternatively, check the tspan’s own BCR intersection with the SVG container BCR (zero intersection = off-screen).
Attack 2: tspan x="5000" — consent tspan displaced 5000 units off-screen right (SA-CSS-TXO-002)
The mirror-image variant uses a large positive x value to displace the tspan off the right edge of the SVG viewport. With x="5000" and a 440px viewport, the tspan’s first character is 5000 SVG user units to the right of the coordinate origin — approximately 4560px beyond the right edge of the viewport. The SVG overflow:hidden default clips this content at x=440. The attack characteristics are identical to the negative-x variant: the parent <text> BCR is non-zero, textContent is intact, and all per-element computed style checks pass. The difference is the direction of displacement. A scanner checking only for x < 0 as an off-screen signal misses this variant; both x << 0 (large negative) and x >> svgWidth (large positive beyond viewport) must be checked. The tspan BCR has BCR.x >> window.innerWidth in the positive case, providing a consistent detection signal: any tspan whose BCR left edge exceeds the SVG container’s right edge is off-screen.
<!-- SA-CSS-TXO-002: tspan x="5000" — absolute x displaces consent tspan 5000 units off-screen right -->
<!-- SVG width=440px; x=5000 is 4560px beyond right edge; overflow=hidden clips -->
<!-- BCR.x >> window.innerWidth; 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"/>
<!-- tspan x="5000": absolute x=5000; SVG width=440; 4560px past right edge -->
<!-- overflow=hidden: clipped at x=440; zero tspan pixels rendered within viewport -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan x="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: check tspan BCR intersection with SVG container (shared logic with TXO-001)
// isOffRight condition: firstX > svgWidth * 10 (or tBcr.left > svgBcr.right)
// Additional signal: tBcr.left > svgBcr.right (tspan starts beyond SVG right edge)
function checkTspanXOffRight(tspan, svgBcr, svgWidth) {
const xAttr = tspan.getAttribute('x');
if (xAttr === null) return null;
const firstX = parseFloat(xAttr.trim().split(/[\s,]+/)[0]);
if (isNaN(firstX) || firstX <= svgWidth * 2) return null;
const tBcr = tspan.getBoundingClientRect();
const intersection = Math.max(0, Math.min(tBcr.right, svgBcr.right) - Math.max(tBcr.left, svgBcr.left));
return {
severity: 'CRITICAL', code: 'SA-CSS-TXO-002',
note: `<tspan> x="${firstX}"; SVG width=${svgWidth}px; ` +
`x exceeds viewport by ${firstX - svgWidth}px; ` +
`BCR.left=${tBcr.left.toFixed(0)}, svgBcr.right=${svgBcr.right.toFixed(0)}; ` +
`BCR x-intersection=${intersection}px (0 = fully off-screen right); ` +
`textContent="${tspan.textContent.trim().slice(0,30)}..." intact`
};
}
CRITICAL — SA-CSS-TXO-002: tspan x="5000" places the consent tspan 4560px beyond the right edge of a 440px SVG viewport. overflow:hidden clips the content. A scanner checking only for negative x values misses this variant. Detection must check both ends: x << 0 (off left) and x >> svgWidth (off right). The unified check is a BCR intersection test: if the tspan BCR has zero overlap with the SVG container BCR, the tspan is off-screen regardless of which direction.
Attack 3: per-tspan scope — header tspan renders at x="20", consent tspan has x="-5000" (SA-CSS-TXO-003)
The per-tspan scope attack places both the header text and the consent text inside a single <text> element as separate <tspan> children. The header tspan has no x attribute override (or x="20"), so it renders at the parent <text>’s declared position — fully visible. The consent tspan has x="-5000", displacing it off-screen. A scanner inspecting the parent <text> element finds: non-zero BCR (the header tspan contributes to the layout), non-empty textContent (both tspan strings are present), and normal computed style. The header tspan is visually present, suggesting the element renders text correctly. The consent tspan is geometrically absent from the viewport.
This variant exploits the assumption that if a <text> element has visible tspan children, all tspan children are visible. Per-tspan analysis is required: each <tspan> that carries consent-relevant content must be independently checked for off-screen x displacement. The detection function must iterate all <tspan> descendants of every consent-bearing <text> element, not just the parent. Because the header tspan passes the visibility check, the any-visible-tspan heuristic produces a false negative.
<!-- SA-CSS-TXO-003: per-tspan scope — header tspan x=20 (visible); consent tspan x="-5000" (off-screen) -->
<!-- Both tspans are children of the same <text> element; parent BCR reflects header tspan -->
<!-- Any-visible-tspan check finds header tspan and PASSES; per-tspan x-check required -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="140" fill="white"/>
<!-- header tspan: no x override; inherits parent x=20; renders at x=20; visible -->
<!-- consent tspan: x="-5000"; absolute override; off-screen left; clipped -->
<!-- parent BCR non-zero (header tspan contributes layout); textContent has both strings -->
<text x="20" font-size="13" fill="#111">
<tspan y="30">Permission Request:</tspan>
<tspan y="50" x="-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 iteration inside consent text elements
function checkPerTspanXOffset(svgEl) {
const findings = [];
const svgBcr = svgEl.getBoundingClientRect();
const svgWidth = svgEl.viewBox?.baseVal?.width || svgEl.clientWidth || 440;
for (const tspan of svgEl.querySelectorAll('text tspan')) {
const xAttr = tspan.getAttribute('x');
if (xAttr === null) continue;
const firstX = parseFloat(xAttr.trim().split(/[\s,]+/)[0]);
if (isNaN(firstX)) continue;
const isOffLeft = firstX < -(svgWidth * 2);
const isOffRight = firstX > svgWidth * 10;
if (!isOffLeft && !isOffRight) continue;
const tBcr = tspan.getBoundingClientRect();
const intersectionX = Math.max(0,
Math.min(tBcr.right, svgBcr.right) - Math.max(tBcr.left, svgBcr.left));
const intersectionY = Math.max(0,
Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top, svgBcr.top));
findings.push({
severity: 'HIGH', code: 'SA-CSS-TXO-003',
note: `<tspan> x="${firstX}" (off-screen ${isOffLeft ? 'LEFT' : 'RIGHT'}); ` +
`sibling tspan(s) have no x override and render visibly; ` +
`parent <text> BCR non-zero (sibling tspan contributes); ` +
`BCR intersection area=${intersectionX * intersectionY}px² (0 = fully clipped); ` +
`any-visible-tspan check PASSES (header tspan visible); ` +
`per-tspan x-attribute inspection required for consent content; ` +
`textContent="${tspan.textContent.trim().slice(0,30)}..."`
});
}
return findings;
}
HIGH — SA-CSS-TXO-003: The consent <tspan> has x="-5000" while the sibling header <tspan> renders normally. The parent <text> BCR is non-zero (header tspan contributes); a scanner finding visible content in the parent element cannot conclude that all tspan children are visible. Each consent-bearing <tspan> must be independently checked: read the tspan’s own x attribute and verify the absolute coordinate falls within the SVG viewport, or compute the tspan BCR intersection with the SVG container.
Attack 4: JS mousedown injects x="-5000" on consent tspan, reverts at mouseup (SA-CSS-TXO-004)
The dynamic form of the tspan-x attack sets the tspan’s x attribute to "-5000" precisely during a mouse press. At rest, the consent tspan has no x attribute (or x="20"), rendering normally. A mousedown event listener on the SVG container (in the capture phase) calls tspan.setAttribute(‘x’, ‘-5000’). The rendering engine immediately repositions the tspan to x=-5000, placing it off-screen; the repaint occurs before any click handler in the bubble phase fires. At mouseup, the attribute is restored. The consent text is invisible during the click window; static inspection at rest finds normal values.
The mutation can also be delivered via an inline style containing a translateX or via a CSS class that sets the x attribute indirectly using a CSS custom property (though SVG presentation attributes cannot be driven by CSS custom properties in current browsers, a style containing a transform with a large translation achieves the same displacement). Detection requires a MutationObserver on the tspan’s x attribute; the observer must be configured with attributeFilter: [‘x’] and subtree: true relative to the <text> parent. After observing a mutation that changes x to a large value, the scanner should compute the tspan BCR and confirm zero overlap with the SVG container to distinguish a genuine attack from a legitimate repositioning.
<!-- SA-CSS-TXO-004: at rest no x override (tspan renders at parent x=20); -->
<!-- mousedown injects x="-5000"; tspan jumps off-screen; reverts at mouseup -->
<!-- static scan finds normal x; BCR normal at rest; dynamic scan required -->
<svg id="consent-svg-txo" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- at rest: tspan has no x attr; inherits parent x=20; renders normally -->
<!-- mousedown: setAttribute('x', '-5000'); tspan jumps 5000px off left; clipped -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan id="consent-tspan-txo">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-txo');
const tspan = document.getElementById('consent-tspan-txo');
svg.addEventListener('mousedown', () => {
tspan.setAttribute('x', '-5000'); // absolute x; tspan jumps off-screen
}, true); // capture phase — fires before bubble-phase click handlers
svg.addEventListener('mouseup', () => {
tspan.removeAttribute('x'); // restore normal position
}, true);
})();
</script>
// Detection: MutationObserver on x attribute of tspan elements + BCR verification
function watchTspanXInject(svgEl) {
const findings = [];
const svgBcr = svgEl.getBoundingClientRect();
const svgWidth = svgEl.viewBox?.baseVal?.width || svgEl.clientWidth || 440;
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'x') continue;
const tspan = mut.target;
if (tspan.tagName !== 'tspan') continue;
const newX = parseFloat(tspan.getAttribute('x') || '0');
const isOffScreen = newX < -(svgWidth * 2) || newX > svgWidth * 10;
if (!isOffScreen) continue;
const tBcr = tspan.getBoundingClientRect();
const intersectionX = Math.max(0,
Math.min(tBcr.right, svgBcr.right) - Math.max(tBcr.left, svgBcr.left));
const intersectionY = Math.max(0,
Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top, svgBcr.top));
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-TXO-004',
note: `Dynamic x="${newX}" injected on <tspan> via setAttribute during interaction; ` +
`SVG width=${svgWidth}px; off-screen by ${Math.abs(newX) - svgWidth}px; ` +
`BCR intersection area=${intersectionX * intersectionY}px² after inject; ` +
`at rest: no x attr (renders at parent x=20; normal BCR); static checks PASS; ` +
`MutationObserver attributeFilter=['x'] subtree=true on parent text required; ` +
`synthetic mousedown triggers inject; BCR=0 confirms off-screen displacement; ` +
`textContent="${tspan.textContent?.trim().slice(0,30) ?? ''}..." intact`
});
}
});
// Observe x attribute mutations on all tspan elements inside the SVG
for (const tspan of svgEl.querySelectorAll('tspan')) {
observer.observe(tspan, { attributes: true, attributeFilter: ['x'] });
}
// Synthesize interaction to trigger dynamic attack
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-TXO-004: JavaScript injects x="-5000" on the consent <tspan> at mousedown, displacing it off-screen during the click window. At rest the tspan has no x attribute and renders normally; all static checks pass. Detection requires a MutationObserver with attributeFilter: [‘x’] watching consent tspan elements, combined with a synthetic mousedown dispatch and post-mutation BCR intersection check (zero overlap = off-screen).
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-TXO-001: tspan x="-5000" — off-screen left | tspan x attribute sets absolute SVG x coordinate; x=-5000 is 5000px left of viewport in 440px SVG; overflow:hidden clips; parent text BCR non-zero; textContent intact | textContent; parent BCR; getComputedStyle (no hidden/clip/opacity markers); requires: parse tspan x attribute as SVG coordinate; check |x| vs viewport width; or check tspan BCR intersection with SVG container BCR | Critical |
| SA-CSS-TXO-002: tspan x="5000" — off-screen right | x=5000 places tspan 4560px past right edge of 440px SVG; overflow:hidden clips; BCR.x >> window.innerWidth; tspan BCR has zero intersection with SVG container | textContent; parent BCR; negative-x-only scanner; requires: check x > svgWidth * 10 (off right) AND x < -(svgWidth * 2) (off left); or unified BCR intersection test | Critical |
| SA-CSS-TXO-003: per-tspan scope — header tspan visible; consent tspan x="-5000" | Header tspan no x override (renders at parent x=20); consent tspan x="-5000" (off-screen); parent BCR non-zero (header contributes); any-visible-tspan check passes | textContent; any-visible-tspan check; parent BCR; requires: per-tspan x attribute inspection for each consent-bearing tspan independently | High |
| SA-CSS-TXO-004: mousedown injects x="-5000"; reverts mouseup | At rest: no x attr; tspan at parent x=20; BCR normal; static checks pass. mousedown: x="-5000"; tspan off-screen; BCR intersection=0. mouseup: x removed; revert | Static DOM; requires: MutationObserver attributeFilter=['x'] on tspan + synthetic mousedown + BCR intersection check after mutation (0 overlap confirms off-screen) | Critical |