Security Guide
MCP server SVG tspan rotate consent security — rotate="90" spins each consent glyph 90° into an unreadable vertical stroke, rotate="180" renders glyphs upside-down, per-tspan scope hides only the consent characters while the header renders normally, and mousedown injects the rotation during the click window
The SVG rotate attribute on <text> and <tspan> elements specifies per-character glyph rotation in degrees. Unlike transform="rotate(90)", which rotates the entire element as a rigid body and changes its coordinate-space position, rotate spins each individual glyph around its own origin at the baseline, leaving the text at its normal declared coordinates. Setting rotate="90" on a tspan renders each character of the consent text as a vertical stroke — letters become column-like thin slices, unreadable without the spatial arrangement of a horizontal baseline. The consent string is structurally present: textContent is intact, the tspan’s getBoundingClientRect() is non-zero, the element is in the DOM with normal display and visibility. The critical blind spot is that getComputedStyle does not expose the SVG rotate presentation attribute — it is not a CSS property — so CSS-based auditors are entirely blind to the rotation. Detection requires getAttribute(‘rotate’) directly on the tspan element and inspection of the numeric rotation value. Four attack patterns: rotate="90" vertical strokes, rotate="180" upside-down glyphs, per-tspan glyph scope, and mousedown injection.
Attack 1: tspan rotate="90" — each consent glyph rotated 90° clockwise, rendered as vertical tick marks (SA-CSS-TRT-001)
The SVG rotate attribute for text elements takes a list of rotation values (in degrees), one per character. A single value applies to all characters. Each character glyph is rotated clockwise by the specified angle around its own typographic origin — the point defined by the character’s advance position and baseline. Unlike a CSS transform: rotate(90deg) applied to the whole element, this per-character rotation does not translate the characters out of their normal baseline positions. Each character remains at its declared x/y position on the baseline; only the glyph’s orientation changes.
At rotate="90", each character is turned 90° clockwise. The glyph’s tall vertical strokes (normally forming the spine of letters like “l”, “h”, “d”) are now horizontal. The glyph’s horizontal strokes (crossbars of “e”, “t”, etc.) are now vertical. The character spacing is preserved at normal advance widths, but the rendered glyphs are visually indistinguishable from random vertical marks spaced along a baseline. A user presented with rotate="90" consent text cannot read the permission scope. The entire text element remains fully in the DOM and fully in the viewport — the attack is not geometric displacement but glyph-level rendering corruption.
<!-- SA-CSS-TRT-001: tspan rotate="90" — each glyph rotated 90° clockwise in place -->
<!-- per-character rotation around glyph origin; text stays at x=20, y=50 -->
<!-- BCR non-zero (element in viewport, occupies layout space) -->
<!-- textContent intact; getComputedStyle does NOT expose rotate attribute (not a CSS property) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- rotate="90": each character spun 90°; consent text rendered as column of vertical marks -->
<!-- text position unchanged; BCR reflects normal layout extent -->
<!-- getComputedStyle(tspan).rotate = "" (no CSS rotate property; attribute-only) -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan rotate="90">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: getAttribute('rotate') on tspan — not available via getComputedStyle
function checkTspanRotate(svgEl) {
const findings = [];
for (const tspan of svgEl.querySelectorAll('tspan')) {
const rotateAttr = tspan.getAttribute('rotate');
if (rotateAttr === null) continue;
// rotate can be a space-separated list; single value applies to all chars
const firstRotate = parseFloat(rotateAttr.trim().split(/[\s,]+/)[0]);
if (isNaN(firstRotate)) continue;
// Normalize to [0, 360)
const normalized = ((firstRotate % 360) + 360) % 360;
// Unreadable thresholds:
// 60°-120° (tilted into near-vertical; hard to read) and
// 240°-300° (same via full-rotation equivalence) → CRITICAL
// 150°-210° (inverted; significantly harder to read) → HIGH
const isUnreadable = (normalized > 60 && normalized < 120) ||
(normalized > 240 && normalized < 300);
const isInverted = (normalized >= 150 && normalized <= 210);
if (isUnreadable || isInverted) {
const tBcr = tspan.getBoundingClientRect();
findings.push({
severity: isUnreadable ? 'CRITICAL' : 'HIGH',
code: isUnreadable ? 'SA-CSS-TRT-001' : 'SA-CSS-TRT-002',
note: `<tspan> rotate="${rotateAttr}"; normalized=${normalized}°; ` +
(isUnreadable ? 'glyphs rotated into near-vertical → unreadable vertical marks' : '') +
(isInverted ? 'glyphs inverted (≈180°) → upside-down text; hard to read' : '') +
`; BCR=${tBcr.width.toFixed(0)}×${tBcr.height.toFixed(0)}px (non-zero; in viewport); ` +
`getComputedStyle.rotate="" (CSS property absent; attribute-only); ` +
`textContent="${tspan.textContent.trim().slice(0,30)}..." intact; ` +
`detection: getAttribute('rotate') required; getComputedStyle misses this`
});
}
}
return findings;
}
CRITICAL — SA-CSS-TRT-001: tspan rotate="90" rotates each consent glyph 90° clockwise in place. Characters are rendered as vertical strokes spaced along a normal horizontal baseline — visually unreadable. The tspan BCR is non-zero, textContent is intact, and getComputedStyle does not expose the SVG rotate presentation attribute. Detection requires calling getAttribute(‘rotate’) directly on the tspan element and flagging rotation values in the 60°–120° range (near-vertical, unreadable).
Attack 2: tspan rotate="180" — each consent glyph inverted upside-down (SA-CSS-TRT-002)
At rotate="180", each glyph is rotated 180° clockwise — the equivalent of flipping the character upside-down and horizontally mirroring it. The text is present in the viewport and in the DOM, but each character is inverted. A user presented with 180° rotated consent text must mentally invert and reverse every character to read the permission scope — a significant cognitive barrier that effectively prevents informed consent. Unlike the 90° variant (which renders glyphs as unrecognizable strokes), 180° rotation preserves glyph structure: the letters are still present, just inverted. An automated scanner relying only on textContent and BCR fails to detect this; the rendered consent is unintelligible to a user but the DOM representation is intact.
<!-- SA-CSS-TRT-002: tspan rotate="180" — each glyph inverted (180° rotation in place) -->
<!-- characters flip upside-down; text present in DOM but visually inverted; hard to read -->
<!-- BCR same width/height as unrotated text; textContent intact; getComputedStyle misses this -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- rotate="180": 180° per-glyph rotation; each char upside-down -->
<!-- text at x=20, y=50; BCR reflects normal layout; glyph pixels are inverted -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan rotate="180">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: flag normalize rotate values in the near-180° range (upside-down)
// 150°-210° normalized is effectively upside-down; add to checkTspanRotate above
// Example:
function isReadablyRotated(rotateDeg) {
const normalized = ((rotateDeg % 360) + 360) % 360;
// 0°-30° or 330°-360°: essentially upright; readable
// 60°-120° or 240°-300°: sideways/vertical; unreadable (CRITICAL)
// 150°-210°: upside-down; hard to read (HIGH)
if ((normalized >= 60 && normalized <= 120) ||
(normalized >= 240 && normalized <= 300)) return false; // unreadable
if (normalized >= 150 && normalized <= 210) return false; // inverted
return true; // within ±30° of upright; readable
}
HIGH — SA-CSS-TRT-002: tspan rotate="180" inverts each consent glyph upside-down. The text is in the viewport and DOM with intact textContent and non-zero BCR, but the glyphs are visually inverted and unintelligible without deliberate reading effort. getComputedStyle is blind to the rotation. Detection checks getAttribute(‘rotate’) and flags values in the 150°–210° normalized range (near-inversion).
Attack 3: per-tspan glyph scope — header tspan rotate="0" (readable), consent tspan rotate="90" (unreadable vertical marks) (SA-CSS-TRT-003)
The per-tspan scope attack places the header text and the consent text in separate <tspan> children of the same <text> element. The header tspan has no rotate attribute (defaults to 0), rendering normally. The consent tspan has rotate="90", rendering all consent characters as unreadable vertical strokes. A scanner inspecting the parent element finds a non-zero BCR and intact textContent. The header tspan is visually readable, suggesting the element renders text correctly. Only per-tspan attribute inspection reveals that the consent tspan has a glyph-level rotation attack applied. Because getComputedStyle does not expose the SVG rotate attribute, the inspection must use getAttribute(‘rotate’) on each tspan individually.
<!-- SA-CSS-TRT-003: per-tspan scope — header no rotate (glyphs upright; readable); -->
<!-- consent tspan rotate="90" (glyphs vertical; unreadable) -->
<!-- parent BCR non-zero; textContent has both strings; 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 rotate attribute; glyphs at 0°; fully readable -->
<!-- consent tspan: rotate="90"; each glyph 90° clockwise; vertical marks; unreadable -->
<text x="20" font-size="13" fill="#111">
<tspan y="30">Permission Request:</tspan>
<tspan y="50" rotate="90">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 rotate check
function checkPerTspanRotate(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text')) {
for (const tspan of textEl.querySelectorAll('tspan')) {
const rotateAttr = tspan.getAttribute('rotate');
if (rotateAttr === null) continue; // no rotation; readable
const firstRotate = parseFloat(rotateAttr.trim().split(/[\s,]+/)[0]);
if (isNaN(firstRotate)) continue;
const normalized = ((firstRotate % 360) + 360) % 360;
if (isReadablyRotated(normalized)) continue; // upright; ok
// Sibling tspans without rotate (or rotate≈0) render normally
const siblings = Array.from(textEl.querySelectorAll('tspan'))
.filter(s => s !== tspan && isReadablyRotated(
((parseFloat(s.getAttribute('rotate') || '0') % 360) + 360) % 360));
findings.push({
severity: 'HIGH', code: 'SA-CSS-TRT-003',
note: `per-tspan rotate: consent tspan rotate="${rotateAttr}" (${normalized}°; unreadable); ` +
`sibling tspan(s) without rotate attr render normally; ` +
`parent BCR non-zero; any-visible-tspan check PASSES; ` +
`per-tspan getAttribute('rotate') required for each consent tspan; ` +
`getComputedStyle does not expose SVG rotate attribute; ` +
`textContent="${tspan.textContent.trim().slice(0,30)}..." intact`
});
}
}
return findings;
}
HIGH — SA-CSS-TRT-003: The consent <tspan> has rotate="90" while the sibling header tspan renders normally without rotation. The parent BCR is non-zero (header tspan contributes); an any-visible-tspan check passes because the header is readable. Detection requires iterating over each <tspan> individually and calling getAttribute(‘rotate’) — getComputedStyle is not sufficient for SVG presentation attributes.
Attack 4: JS mousedown injects rotate="90" on consent tspan, reverts at mouseup (SA-CSS-TRT-004)
The dynamic form sets rotate="90" on the consent tspan during a mouse press. At rest, the tspan has no rotate attribute, and all glyphs render normally at 0°. A mousedown listener (capture phase) calls tspan.setAttribute(‘rotate’, ‘90’). The rendering engine immediately redraws all consent glyphs as 90°-rotated vertical marks before any bubble-phase click handler fires. At mouseup, the attribute is removed and the glyphs return to their normal upright orientation. The consent text is visually unreadable during the click window; static inspection at rest finds no rotate attribute. This is a timing-window attack: the user clicks “Allow” while visually unable to read the permission scope, but the consent dialog reappears in readable form if the user looks at it at any other time.
Detection requires a MutationObserver with attributeFilter: [‘rotate’] on consent tspan elements. The rotate attribute is a SVG presentation attribute, not a CSS property, so style-attribute observation misses it entirely. A synthetic mousedown event triggers the injection; the observer fires when the attribute is set; the rotation value is then checked against the unreadable threshold.
<!-- SA-CSS-TRT-004: at rest no rotate (glyphs at 0°; text fully readable) -->
<!-- mousedown: setAttribute('rotate', '90'); glyphs become vertical marks during click -->
<!-- mouseup: removeAttribute('rotate'); glyphs return to 0°; text readable again -->
<svg id="consent-svg-trt" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- at rest: no rotate attr; glyphs upright (0°); text readable -->
<!-- mousedown: rotate="90"; all glyphs spin to vertical; unreadable during click -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan id="consent-tspan-trt">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-trt');
const tspan = document.getElementById('consent-tspan-trt');
svg.addEventListener('mousedown', () => {
tspan.setAttribute('rotate', '90'); // per-glyph rotation; consent text becomes unreadable
}, true);
svg.addEventListener('mouseup', () => {
tspan.removeAttribute('rotate'); // restore 0° rotation; text readable again
}, true);
})();
</script>
// Detection: MutationObserver on rotate attribute (SVG presentation attr; not CSS)
function watchTspanRotateInject(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'rotate') continue;
const tspan = mut.target;
if (tspan.tagName !== 'tspan') continue;
const rotateAttr = tspan.getAttribute('rotate');
if (rotateAttr === null) continue;
const firstRotate = parseFloat(rotateAttr.trim().split(/[\s,]+/)[0]);
if (isNaN(firstRotate)) continue;
const normalized = ((firstRotate % 360) + 360) % 360;
if (isReadablyRotated(normalized)) continue;
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-TRT-004',
note: `Dynamic rotate="${firstRotate}" (${normalized}°) injected on <tspan> via ` +
`setAttribute during mousedown; glyphs spin to unreadable orientation; ` +
`at rest: no rotate attr (glyphs at 0°; text fully readable); static checks PASS; ` +
`MutationObserver attributeFilter=['rotate'] required; ` +
`rotate is SVG presentation attribute; style observer alone MISSES; ` +
`synthetic mousedown triggers inject; ` +
`textContent="${tspan.textContent?.trim().slice(0,30) ?? ''}..." intact; ` +
`attack window = mousedown to mouseup (user clicks Allow while text unreadable)`
});
}
});
for (const tspan of svgEl.querySelectorAll('tspan')) {
observer.observe(tspan, { attributes: true, attributeFilter: ['rotate'] });
}
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-TRT-004: JavaScript injects rotate="90" on the consent <tspan> at mousedown, rendering consent glyphs as unreadable vertical marks during the click window. At rest there is no rotate attribute and glyphs are normal. getComputedStyle never exposes the SVG rotate attribute; a MutationObserver with attributeFilter: [‘rotate’] is required. The attack window equals the duration of the mouse press — the user clicks Allow while the consent text is visually unreadable.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-TRT-001: tspan rotate="90" — each glyph 90° vertical | Per-character glyph rotation 90° clockwise in place; consent text rendered as column of vertical marks; text position unchanged; BCR non-zero; textContent intact | textContent; BCR; getComputedStyle (rotate is SVG attr, not CSS); requires: getAttribute('rotate') on tspan; flag values 60°-120° (near-vertical; unreadable) | Critical |
| SA-CSS-TRT-002: tspan rotate="180" — each glyph upside-down | 180° per-glyph rotation; glyphs inverted; text in viewport; BCR non-zero; textContent intact; glyphs visually unintelligible without deliberate inversion effort | textContent; BCR; getComputedStyle; requires: getAttribute('rotate'); flag values 150°-210° (near-inversion) | High |
| SA-CSS-TRT-003: per-tspan scope — header no rotate (readable); consent tspan rotate="90" | Header tspan at 0° (readable); consent tspan at 90° (unreadable); parent BCR non-zero (header); any-visible check passes; only consent glyphs rotated | textContent; any-visible-tspan check; parent BCR; requires: per-tspan getAttribute('rotate') for each consent tspan; getComputedStyle insufficient | High |
| SA-CSS-TRT-004: mousedown injects rotate="90"; reverts mouseup | At rest: no rotate; glyphs at 0°; text readable; static checks pass. mousedown: rotate="90"; consent glyphs unreadable. mouseup: attr removed; glyphs return to 0° | Static DOM; getComputedStyle; style-attribute observer (rotate is SVG attr); requires: MutationObserver attributeFilter=['rotate'] + synthetic mousedown | Critical |