Security Guide
MCP server SVG tspan dx offset consent security — dx="-5000" adds a relative −5000-unit horizontal displacement off-screen left, dx="5000" displaces off-screen right, per-tspan relative 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 dx attribute adds a relative horizontal displacement to the current text cursor position. Unlike the x attribute, which overrides the cursor with an absolute coordinate in SVG user-unit space, dx is cumulative: the effective x-position of the tspan’s first character equals the cursor’s current x-position plus the dx value. Setting dx="-5000" on a tspan element moves the tspan 5000 SVG user units to the left of wherever the cursor currently is. For a parent <text> element at x="20" in a 440px viewport, the effective first-character position is 20 + (−5000) = −4980 SVG user units — 4980px left of the SVG’s left 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. Scanners that check the parent element’s properties, or that test for large absolute x values, pass the check while missing the cumulative dx displacement. Four attack patterns: dx="-5000" off-screen left, dx="5000" off-screen right, per-tspan relative scope, and mousedown injection.
Attack 1: tspan dx="-5000" — consent tspan displaced 5000 units left via relative offset, clipped by overflow:hidden (SA-CSS-TDX-001)
The dx attribute on a <tspan> element specifies a relative horizontal shift from the current text cursor position. It is a list-valued attribute: a single value applies to the first character, and the cursor continues from the shifted position for subsequent characters. Setting dx="-5000" on a tspan element does not place the tspan at x=−5000 in absolute coordinates; it moves the cursor left by 5000 SVG user units from wherever it currently is. If the parent <text> element has x="20", the cursor before the tspan is at x=20. The dx=“−5000” offset moves it to x = 20 − 5000 = −4980. The remaining characters of the tspan follow normal advance widths from that starting position, keeping the entire tspan at x < −4900 for typical text lengths. In a 440px viewport with 1:1 user-unit-to-pixel mapping, this is 4980px off the left edge. SVG’s default overflow:hidden clips the content to the viewport boundary.
The distinction from x-based attacks is important for detection. A scanner checking tspan elements for large absolute x attribute values (e.g., x < -1000) will find nothing — there is no x attribute on the tspan. The dx attribute must be independently parsed and added to the cursor’s current position to determine the effective absolute x-coordinate. The parent <text> element’s textContent includes the consent text in full. The BCR of the parent text element is non-zero. getComputedStyle(tspan).display returns inline; there is no visibility:hidden, no opacity:0, no clip-path. Only a dx-aware check that computes the effective cursor position reveals the off-screen displacement.
<!-- SA-CSS-TDX-001: tspan dx="-5000" — relative horizontal offset; cursor shifts left 5000 units -->
<!-- Parent text x=20; cursor at x=20 before tspan; dx="-5000" → effective x = 20-5000 = -4980 -->
<!-- SVG overflow=hidden (default): content at x<0 clipped; entire tspan off-screen left -->
<!-- textContent of parent <text> includes consent text; parent BCR non-zero -->
<!-- getComputedStyle: display=inline, no opacity/visibility/clip markers -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- dx="-5000": relative; cursor at x=20 before tspan; effective x = 20 + (-5000) = -4980 -->
<!-- overflow=hidden clips x<0; tspan renders zero pixels within viewport -->
<!-- no x attribute on tspan: x-only scanner finds nothing -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan dx="-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 x and check effective position after dx
function checkTspanDxOffset(svgEl) {
const findings = [];
const svgBcr = svgEl.getBoundingClientRect();
const svgWidth = svgEl.viewBox?.baseVal?.width || svgEl.clientWidth || 440;
for (const textEl of svgEl.querySelectorAll('text')) {
// Starting cursor x = parent text x attribute (or 0 if absent)
let cursorX = parseFloat(textEl.getAttribute('x') || '0') || 0;
for (const tspan of textEl.querySelectorAll('tspan')) {
// x attribute resets cursor to absolute position
const xAttr = tspan.getAttribute('x');
if (xAttr !== null) {
cursorX = parseFloat(xAttr.trim().split(/[\s,]+/)[0]) || cursorX;
}
// dx attribute adds relative offset
const dxAttr = tspan.getAttribute('dx');
if (dxAttr !== null) {
const firstDx = parseFloat(dxAttr.trim().split(/[\s,]+/)[0]);
if (!isNaN(firstDx)) {
const effectiveX = cursorX + firstDx;
const isOffLeft = effectiveX < -(svgWidth * 2);
const isOffRight = effectiveX > svgWidth * 10;
if (isOffLeft || isOffRight) {
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-TDX-001',
note: `<tspan> dx="${dxAttr}"; cursorX before tspan=${cursorX}; ` +
`effectiveX=${effectiveX}; SVG width=${svgWidth}px; ` +
(isOffLeft ? `off-screen LEFT by ${Math.abs(effectiveX)}px` : '') +
(isOffRight ? `off-screen RIGHT by ${effectiveX - svgWidth}px` : '') +
`; BCR intersection area=${overlapX * overlapY}px² (0=off-screen); ` +
`no tspan x attribute (x-only scanner MISSES); ` +
`textContent="${tspan.textContent.trim().slice(0,30)}..." intact; ` +
`parent BCR non-zero; overflow=hidden clips off-screen content`
});
}
cursorX = effectiveX; // cursor advances after this tspan
}
}
}
}
return findings;
}
CRITICAL — SA-CSS-TDX-001: tspan dx="-5000" adds a relative −5000 unit horizontal displacement to the current cursor position. For a parent <text> at x=20, the effective tspan x is −4980 — 4980px left of the SVG left edge in a 440px viewport. SVG overflow:hidden clips the content. The tspan has no x attribute; an x-only scanner finds nothing. Detection must parse dx separately, track the cursor’s running x-position, compute the effective absolute position after adding dx, and check if it falls outside the SVG viewport.
Attack 2: tspan dx="5000" — consent tspan displaced 5000 units off-screen right via relative offset (SA-CSS-TDX-002)
The positive-dx variant uses a large positive dx value to displace the tspan to the right of the SVG viewport. With the cursor at x=20 (parent x="20") and dx="5000", the effective first-character x is 20 + 5000 = 5020 — 4580px beyond the right edge of a 440px viewport. SVG overflow:hidden clips content at the right boundary (x=440). The attack is identical in effect to the left-displacement variant: the tspan is geometrically off-screen, textContent is intact, parent BCR is non-zero, and all per-element style checks pass.
Scanners checking only for large negative dx values miss this variant. The threshold for off-screen detection must handle both directions: effective_x << 0 (left of viewport) or effective_x >> svgWidth (right of viewport). The tspan BCR approach provides a direction-agnostic signal: if the tspan BCR has zero intersection area with the SVG container BCR, the tspan is off-screen regardless of direction.
<!-- SA-CSS-TDX-002: tspan dx="5000" — cursor at x=20; effective x = 20+5000 = 5020; off-screen right -->
<!-- SVG width=440px; effective x=5020 is 4580px beyond right edge; overflow=hidden clips -->
<!-- BCR.left >> 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"/>
<!-- dx="5000": relative; cursor at x=20; effective x = 20+5000 = 5020 -->
<!-- 5020 - 440 = 4580px beyond SVG right edge; overflow=hidden clips at x=440 -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan dx="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 after computing effective x
// Works for both positive and negative dx values; direction-agnostic
function checkTspanDxOffRight(tspan, cursorX, svgBcr, svgWidth) {
const dxAttr = tspan.getAttribute('dx');
if (dxAttr === null) return null;
const firstDx = parseFloat(dxAttr.trim().split(/[\s,]+/)[0]);
if (isNaN(firstDx)) return null;
const effectiveX = cursorX + firstDx;
if (effectiveX > -(svgWidth * 2) && effectiveX < svgWidth * 10) return null; // in viewport
const tBcr = tspan.getBoundingClientRect();
const intersectionX = Math.max(0,
Math.min(tBcr.right, svgBcr.right) - Math.max(tBcr.left, svgBcr.left));
return {
severity: 'CRITICAL', code: 'SA-CSS-TDX-002',
note: `<tspan> dx="${firstDx}"; cursorX=${cursorX}; ` +
`effectiveX=${effectiveX}; SVG width=${svgWidth}px; ` +
`x-intersection=${intersectionX}px (0=off-screen); ` +
`BCR.left=${tBcr.left.toFixed(0)}, svgBcr.right=${svgBcr.right.toFixed(0)}; ` +
`textContent="${tspan.textContent.trim().slice(0,30)}..." intact`
};
}
CRITICAL — SA-CSS-TDX-002: tspan dx="5000" with cursor at x=20 produces effective x=5020, which is 4580px beyond the right edge of a 440px viewport. A scanner checking only for negative dx values misses this variant. Detection must check effective position after adding dx against the SVG viewport bounds in both directions; the direction-agnostic approach is to compute the tspan BCR intersection with the SVG container and flag zero-area intersections on consent-bearing tspans.
Attack 3: per-tspan relative scope — header tspan at normal cursor position, consent tspan dx="-5000" (SA-CSS-TDX-003)
The per-tspan scope attack uses two <tspan> children inside the same <text> element. The header tspan has no dx attribute (or an explicit x reset), so it renders at its inherited cursor position — fully visible. After the header tspan renders, the cursor advances to the end of the header text (approximately x=20 + header_width). The consent tspan then applies dx="-5000", moving the cursor 5000 units to the left of this advance position — far enough left to place the entire consent tspan off-screen even if the header is several hundred pixels wide.
A scanner inspecting the parent <text> element finds: a non-zero BCR (the header tspan contributes), non-empty textContent (both tspan strings are present), and normal computed style. The header tspan is visually present, suggesting the element renders text. The consent tspan is off-screen due to the relative dx displacement. An any-visible-tspan heuristic produces a false negative because the header tspan is visible. Per-tspan cursor-tracking is required: the scanner must maintain a running cursor position and evaluate each tspan’s dx or x attribute to determine the effective position of each tspan independently.
<!-- SA-CSS-TDX-003: per-tspan scope — header tspan no dx (renders at cursor x≈20); -->
<!-- cursor advances to ~x=150 after header; consent tspan dx="-5000" → effective x = 150-5000 = -4850 -->
<!-- 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: no dx; cursor at parent x=20; renders at x=20; visible -->
<!-- after header, cursor ≈ x=150 (20 + "Permission Request:" advance width) -->
<!-- consent tspan: dx="-5000"; effective x = ~150 + (-5000) = ~-4850; off-screen left -->
<text x="20" font-size="13" fill="#111">
<tspan y="30">Permission Request:</tspan>
<tspan y="50" dx="-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: cursor-tracking loop over tspan children
function checkPerTspanDxOffset(svgEl) {
const findings = [];
const svgBcr = svgEl.getBoundingClientRect();
const svgWidth = svgEl.viewBox?.baseVal?.width || svgEl.clientWidth || 440;
for (const textEl of svgEl.querySelectorAll('text')) {
let cursorX = parseFloat(textEl.getAttribute('x') || '0') || 0;
for (const tspan of textEl.querySelectorAll('tspan')) {
// x resets cursor to absolute position
const xAttr = tspan.getAttribute('x');
if (xAttr !== null) {
cursorX = parseFloat(xAttr.trim().split(/[\s,]+/)[0]) || cursorX;
}
const dxAttr = tspan.getAttribute('dx');
if (dxAttr !== null) {
const firstDx = parseFloat(dxAttr.trim().split(/[\s,]+/)[0]);
if (!isNaN(firstDx)) {
const effectiveX = cursorX + firstDx;
const isOffLeft = effectiveX < -(svgWidth * 2);
const isOffRight = effectiveX > svgWidth * 10;
if (isOffLeft || isOffRight) {
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-TDX-003',
note: `per-tspan dx: cursor before tspan ≈ ${cursorX}px; dx="${firstDx}"; ` +
`effectiveX=${effectiveX} (${isOffLeft ? 'off LEFT' : 'off RIGHT'}); ` +
`sibling tspan(s) rendered visibly at earlier cursor positions; ` +
`parent BCR non-zero; any-visible-tspan check PASSES; ` +
`BCR intersection area=${overlapArea}px²; ` +
`textContent="${tspan.textContent.trim().slice(0,30)}..." intact`
});
}
cursorX = effectiveX; // advance cursor
}
} else {
// No dx: cursor advances approximately by tspan text advance width
// Use BCR width as a proxy for text advance width
const tBcr = tspan.getBoundingClientRect();
if (tBcr.width > 0) cursorX += tBcr.width / (svgBcr.width / svgWidth);
}
}
}
return findings;
}
HIGH — SA-CSS-TDX-003: The consent <tspan> has dx="-5000"; the cursor position when that tspan is processed is approximately x=150 (after the header tspan’s advance width). Effective position = ~−4850 — off-screen left. The header tspan renders normally and makes the parent BCR non-zero; an any-visible-tspan check passes. Detection requires a cursor-tracking loop over all tspan children, maintaining cumulative cursor position and evaluating each tspan’s dx to determine the effective absolute x.
Attack 4: JS mousedown injects dx="-5000" on consent tspan, reverts at mouseup (SA-CSS-TDX-004)
The dynamic form sets dx="-5000" on the consent tspan exactly during a mouse press. At rest, the tspan has no dx attribute, rendering at its normal cursor-inherited position. A mousedown event listener on the SVG container (capture phase) calls tspan.setAttribute(‘dx’, ‘-5000’). The rendering engine immediately repositions the tspan to x = cursor_x − 5000, placing it off-screen before any bubble-phase click handler fires. At mouseup, the attribute is removed and the tspan returns to its normal position. The consent text is invisible during the click window; static inspection finds no dx attribute at rest.
Detection requires a MutationObserver on the tspan’s dx attribute. The observer must be configured with attributeFilter: [‘dx’] and subtree: true relative to the <text> parent or the SVG root. After observing a mutation that sets dx to a large-magnitude value, the scanner should resolve the tspan’s effective absolute position using the cursor-tracking logic from SA-CSS-TDX-001 and confirm zero BCR overlap with the SVG container.
<!-- SA-CSS-TDX-004: at rest no dx (tspan at cursor-inherited position, renders normally) -->
<!-- mousedown: setAttribute('dx', '-5000'); effective x = cursor-5000; off-screen; clipped -->
<!-- mouseup: removeAttribute('dx'); tspan returns to normal; static scan finds no dx -->
<svg id="consent-svg-tdx" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- at rest: no dx attr; cursor at parent x=20; renders at x=20; normal BCR -->
<!-- mousedown: dx="-5000"; effective x = 20-5000 = -4980; off-screen left -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan id="consent-tspan-tdx">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-tdx');
const tspan = document.getElementById('consent-tspan-tdx');
svg.addEventListener('mousedown', () => {
tspan.setAttribute('dx', '-5000'); // relative offset; effective x = cursor-5000; off-screen
}, true); // capture phase — fires before bubble-phase click handlers
svg.addEventListener('mouseup', () => {
tspan.removeAttribute('dx'); // restore; tspan returns to cursor-inherited position
}, true);
})();
</script>
// Detection: MutationObserver on dx attribute + BCR verification
function watchTspanDxInject(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 !== 'dx') continue;
const tspan = mut.target;
if (tspan.tagName !== 'tspan') continue;
const dxAttr = tspan.getAttribute('dx');
if (dxAttr === null) continue;
const firstDx = parseFloat(dxAttr.trim().split(/[\s,]+/)[0]);
if (isNaN(firstDx)) continue;
// Compute effective x using BCR (reflects actual rendered position after mutation)
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));
if (intersectionX * intersectionY === 0 && Math.abs(firstDx) > svgWidth * 5) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-TDX-004',
note: `Dynamic dx="${firstDx}" injected on <tspan> via setAttribute during interaction; ` +
`SVG width=${svgWidth}px; |dx|=${Math.abs(firstDx)} >> viewport; ` +
`BCR intersection area=0 after inject (off-screen confirmed); ` +
`at rest: no dx attr (cursor-inherited position; normal BCR); static checks PASS; ` +
`MutationObserver attributeFilter=['dx'] subtree=true required; ` +
`synthetic mousedown triggers inject; ` +
`textContent="${tspan.textContent?.trim().slice(0,30) ?? ''}..." intact`
});
}
}
});
for (const tspan of svgEl.querySelectorAll('tspan')) {
observer.observe(tspan, { attributes: true, attributeFilter: ['dx'] });
}
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-TDX-004: JavaScript injects dx="-5000" on the consent <tspan> at mousedown, displacing it off-screen during the click window. At rest the tspan has no dx attribute and renders at its cursor-inherited position; all static checks pass. Detection requires a MutationObserver with attributeFilter: [‘dx’] watching consent tspan elements, combined with a synthetic mousedown dispatch and post-mutation BCR intersection check.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-TDX-001: tspan dx="-5000" — off-screen left | dx adds relative -5000 offset to current cursor position (cursor at x=20); effective x = -4980; 4980px left of 440px viewport; overflow:hidden clips; parent BCR non-zero; textContent intact | textContent; parent BCR; getComputedStyle; tspan x-attribute scanner (no x attr); requires: parse dx, track cursor position, compute effective x, check vs viewport bounds | Critical |
| SA-CSS-TDX-002: tspan dx="5000" — off-screen right | dx=5000 at cursor x=20; effective x=5020; 4580px past right edge of 440px SVG; overflow:hidden clips; tspan BCR zero intersection with SVG container | textContent; parent BCR; negative-dx-only scanner; requires: check effective x >> svgWidth; or unified BCR intersection test | Critical |
| SA-CSS-TDX-003: per-tspan scope — header renders normally; consent tspan dx="-5000" | Header tspan no dx (cursor-inherited position; visible); cursor advances after header; consent tspan dx="-5000" from advanced cursor → effective x off-screen; parent BCR non-zero | textContent; any-visible-tspan check; parent BCR; requires: cursor-tracking loop over tspan children, evaluate each dx to compute effective position independently | High |
| SA-CSS-TDX-004: mousedown injects dx="-5000"; reverts mouseup | At rest: no dx; cursor-inherited position; BCR normal; static checks pass. mousedown: dx="-5000"; effective x off-screen; BCR intersection=0. mouseup: dx removed | Static DOM; requires: MutationObserver attributeFilter=['dx'] on tspan + synthetic mousedown + BCR intersection check after mutation | Critical |