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