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

AttackMechanismWhat it bypassesSeverity
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

SkillAudit findings for this attack surface

CRITICAL SA-CSS-TRT-001: SVG tspan rotate="90" on consent tspan — the SVG rotate attribute on text/tspan elements specifies per-character glyph rotation in degrees; rotate="90" spins each individual glyph 90° clockwise around its own typographic origin; each character remains at its normal x,y baseline position but the glyph image is rotated; the result is each character appearing as a near-vertical stroke or mark that is visually unreadable without the spatial context of a horizontal baseline; the tspan occupies normal layout space (BCR non-zero), textContent is intact, display is inline, visibility is visible; getComputedStyle does not expose the SVG rotate presentation attribute — it is not mapped to a CSS property — so CSS-based auditors are entirely blind to this attack; detection requires calling getAttribute('rotate') directly on each tspan element and checking the first numeric value in the space-separated list against the unreadable threshold (60°-120° and 240°-300° when normalized to [0, 360)).
HIGH SA-CSS-TRT-002: SVG tspan rotate="180" on consent tspan — 180° per-glyph rotation inverts each consent character upside-down; the glyphs retain their shape and form but are visually mirrored and inverted, requiring deliberate effort to decode; the text occupies normal viewport space with a non-zero BCR; textContent is intact; getComputedStyle is blind to the SVG rotate attribute; this attack is rated HIGH rather than CRITICAL because it preserves glyph structure (characters are recognizable, just inverted) compared to 90° rotation (characters become indistinguishable strokes); detection checks getAttribute('rotate') and flags the 150°-210° normalized range.
HIGH SA-CSS-TRT-003: per-tspan glyph scope — header tspan has no rotate attribute (glyphs at default 0°; fully readable); consent tspan has rotate="90" (each glyph 90° clockwise; vertical marks; unreadable); both tspans are children of the same parent text element; parent BCR is non-zero because the header tspan occupies normal layout space; any-visible-tspan check passes because the header tspan is readable; per-tspan getAttribute('rotate') is required for each consent-bearing tspan; getComputedStyle is not sufficient as it does not expose the SVG rotate presentation attribute; the consent tspan's textContent is intact; the attack is at the glyph rendering layer, not the DOM or CSSOM layer.
CRITICAL SA-CSS-TRT-004: JS mousedown injects rotate="90" on consent tspan during click initiation; each consent glyph rotates to a vertical mark (unreadable) before any bubble-phase click handler fires; at mouseup, the rotate attribute is removed and glyphs return to 0°; at rest no rotate attribute — static scan and getComputedStyle both pass; the rotate attribute is a SVG presentation attribute and is not exposed through getComputedStyle — a style-attribute MutationObserver does not catch this; detection requires MutationObserver with attributeFilter: ['rotate'] observing consent tspan elements; a synthetic mousedown dispatch triggers the injection; the attack window equals the mouse press duration — the user presses Allow or Deny while the consent text is visually unreadable, preventing informed consent.

Related: SVG tspan x absolute offset consent attacks  |  SVG direction:rtl consent attacks  |  SVG transform consent attacks

← Blog  |  Security Checklist