Security Guide

MCP server SVG direction="rtl" consent security — right-to-left text flow from x="0" displaces consent text entirely into negative-x space (clipped by overflow:hidden), text-anchor="end" compounds the displacement for small x values, per-element scope renders the header LTR while consent text flows off-screen, and mousedown injects direction:rtl during the click window

The SVG direction presentation attribute and the CSS direction property control the inline text direction for an SVG <text> element. With direction="rtl", characters are laid out from right to left: the element’s anchor point is at its rightmost character, and each subsequent character extends leftward. Combined with a small x coordinate (e.g., x="0"), the rightmost character of the consent text is placed at x=0 and all remaining characters extend to negative x values. In a 440px SVG with overflow:hidden, content at x < 0 is clipped — a 40-character consent string at 7px per character spans 280px, so direction="rtl" with x="0" places 100% of the text off the left edge. The element’s textContent is intact, display and visibility are normal, and fill is a dark color; a scanner checking only these per-element properties passes the check. Four attack patterns: direction=“rtl” + x=”0”, compound with text-anchor=“end”, per-element scope, and mousedown injection.

Attack 1: direction="rtl" + x="0" — RTL text extends leftward from x=0; all characters at x<0, clipped by overflow:hidden (SA-CSS-DIR-001)

In the SVG text model, the x attribute sets the anchor point of the first character. For left-to-right text (direction="ltr", the default), the first character’s left edge is at x and subsequent characters extend rightward. For right-to-left text (direction="rtl"), the text model is reversed: the anchor point corresponds to the rightmost character’s right edge (or the first character when counting from the right), and characters extend leftward. Setting x="0" with direction="rtl" places the anchor at x=0; all character positions are at x < 0 (to the left of the anchor). The SVG coordinate system has its origin at the top-left corner of the viewport, so x < 0 is outside the viewport to the left. The default overflow="hidden" clips this content.

The displacement magnitude depends on the text string length and the font metrics. For a 13px font, a typical advance width is approximately 6-8px per character. The string “Allow: shell execution, credential access” (43 characters including spaces) has an approximate advance width of 43 × 7 = 301px. With direction="rtl" and x="0", the text spans from approximately x=-301 to x=0. With overflow="hidden", all characters at x<0 are clipped and none are rendered within the SVG viewport. A scanner checking textContent (intact), getBoundingClientRect() (BCR.right may equal svgBcr.left = effectively at x=0 with zero visible width), and computed style properties (normal) passes all checks. Detection requires reading the direction attribute (or computed CSS direction) and x attribute in combination: if direction=rtl and x ≤ approx. text-width, the text may be entirely or partially off-screen left.

<!-- SA-CSS-DIR-001: direction="rtl" + x="0" — RTL consent text extends leftward from x=0 -->
<!-- 43-char string × ~7px/char = ~301px; x=0; entire text at x∈[-301, 0] -->
<!-- SVG overflow=hidden: x<0 clipped; zero consent pixels rendered in [0, 440] range -->
<!-- textContent intact; BCR.right ≈ svgBcr.left (at x=0 anchor; zero visible width) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- direction="rtl": text flows rightmost-char first leftward -->
  <!-- x="0": anchor at x=0; characters extend left into x<0 territory -->
  <!-- overflow=hidden: content at x<0 clipped; BCR.right ≈ svgLeft+0; zero visible chars -->
  <text x="0" y="40" font-size="13" fill="#111" direction="rtl">
    Allow: shell execution, credential access
  </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 direction + x combination for RTL off-screen displacement
function checkDirectionRtl(svgEl) {
  const findings = [];
  const svgBcr   = svgEl.getBoundingClientRect();
  const svgWidth = svgEl.viewBox?.baseVal?.width || svgEl.clientWidth || 440;

  for (const el of svgEl.querySelectorAll('text')) {
    const content = el.textContent.trim();
    if (!content) continue;

    // Check direction attribute or computed CSS property
    const dirAttr = el.getAttribute('direction') || '';
    const dirCSS  = getComputedStyle(el).direction || '';
    const isRtl   = dirAttr === 'rtl' || dirCSS === 'rtl';
    if (!isRtl) continue;

    // Get the x anchor point
    const xVal = parseFloat(el.getAttribute('x') || '0');

    // Estimate text width: BCR width is a direct measure
    const elBcr = el.getBoundingClientRect();
    const textWidthEstimate = elBcr.width > 0 ? elBcr.width : content.length * 7;

    // For RTL text at anchor x: text spans [x - textWidth, x]
    // If x - textWidth < 0 and x is small, text is partially or fully off-screen left
    const visibleLeft  = Math.max(0, xVal - textWidthEstimate); // leftmost visible x in SVG coords
    const visibleRight = xVal; // rightmost extent = anchor point
    const visibleWidth = visibleRight - visibleLeft;
    const hiddenFraction = visibleWidth < textWidthEstimate
      ? 1 - (visibleWidth / textWidthEstimate) : 0;

    if (hiddenFraction > 0.5) { // >50% of text is off-screen left
      // BCR intersection as secondary check
      const overlapX = Math.max(0,
        Math.min(elBcr.right,  svgBcr.right)  - Math.max(elBcr.left, svgBcr.left));
      const overlapY = Math.max(0,
        Math.min(elBcr.bottom, svgBcr.bottom) - Math.max(elBcr.top,  svgBcr.top));

      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-DIR-001',
        note: `<text> direction="rtl" (isRtl=true via ${dirAttr ? 'attr' : 'CSS'}); ` +
              `x="${xVal}"; textWidth≈${textWidthEstimate.toFixed(0)}px; ` +
              `RTL anchor at x=${xVal}; text spans [${(xVal - textWidthEstimate).toFixed(0)}, ${xVal}]; ` +
              `${(hiddenFraction * 100).toFixed(0)}% of text at x<0 (clipped by overflow:hidden); ` +
              `BCR intersection area=${overlapX * overlapY}px² (low/zero = mostly off-screen); ` +
              `textContent="${content.slice(0,30)}..." intact; display=inline; fill="#111" (dark); ` +
              `getComputedStyle direction="${dirCSS}"; direction attr="${dirAttr}"`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-DIR-001: direction="rtl" with x="0" causes RTL text to extend entirely leftward from x=0 into negative-x space. For a 43-character consent string at ~7px/char, all characters are at x ∈ [-301, 0], fully outside the visible viewport. overflow:hidden clips the content. textContent is intact; display and fill are normal. Detection must read the element’s direction attribute or computed CSS direction and, for RTL text, compute the expected text extent leftward from the anchor x; if that extent falls outside the viewport, flag the element.

Attack 2: direction="rtl" + text-anchor="end" + small x — compound displacement for subtler RTL off-screen (SA-CSS-DIR-002)

The text-anchor attribute controls the horizontal alignment of a text element relative to its x coordinate. The default value is "start" (for LTR: left edge at x; for RTL: right edge at x). Setting text-anchor="end" for RTL text means the left edge of the text string is at x — the opposite of the default RTL behavior. With direction="rtl" and text-anchor="end" and x="20", the left edge of the text block is at x=20. The text then extends rightward from x=20; this would actually render the text within the viewport. However, the rendering of RTL text is reversed: the first visual character (from the user’s perspective) is at the rightmost position, so even though the block is within the viewport, the text reads right-to-left, which may confuse users or make permission scope terms difficult to parse.

A more effective compound attack uses direction="rtl" + text-anchor="start" (default for LTR, but for RTL it means the anchor is at the right edge) + x="0", combined with an explicit unicode-bidi="bidi-override" which forces right-to-left character order regardless of the Unicode bidirectional properties of the characters. With unicode-bidi="bidi-override" and direction="rtl", Latin characters in the consent string are reversed in visual display order (the last character is visually leftmost, the first is rightmost), making the text illegible even if some portion remains within the viewport. Detection must check for both direction="rtl" and unicode-bidi="bidi-override" on consent text elements.

<!-- SA-CSS-DIR-002: direction="rtl" + unicode-bidi="bidi-override" reverses char order -->
<!-- With bidi-override, Latin chars display in reversed visual order (illegible) -->
<!-- Combined with x="0": text also displaced off-screen (double attack) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- direction="rtl" + unicode-bidi="bidi-override": -->
  <!-- even if some text is in viewport, char order is reversed; "ssecca" not "access" -->
  <!-- + x="0": text also displaced off-screen left for most of its length -->
  <text x="0" y="40" font-size="13" fill="#111"
        direction="rtl" unicode-bidi="bidi-override">
    Allow: shell execution, credential access
  </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 for direction=rtl combined with unicode-bidi=bidi-override
function checkBidiOverrideCompound(svgEl) {
  const findings = [];
  for (const el of svgEl.querySelectorAll('text')) {
    const dirAttr = el.getAttribute('direction') || '';
    const dirCSS  = getComputedStyle(el).direction || '';
    const bidiAttr = el.getAttribute('unicode-bidi') || '';
    const bidiCSS  = getComputedStyle(el).unicodeBidi || '';

    const isRtl   = dirAttr === 'rtl' || dirCSS === 'rtl';
    const hasBidi = bidiAttr === 'bidi-override' || bidiCSS === 'bidi-override';

    if (!isRtl && !hasBidi) continue;

    const xVal   = parseFloat(el.getAttribute('x') || '0');
    const elBcr  = el.getBoundingClientRect();
    const svgBcr = svgEl.getBoundingClientRect();
    const overX  = Math.max(0,
      Math.min(elBcr.right,  svgBcr.right) - Math.max(elBcr.left, svgBcr.left));
    const overY  = Math.max(0,
      Math.min(elBcr.bottom, svgBcr.bottom) - Math.max(elBcr.top, svgBcr.top));

    findings.push({
      severity: 'CRITICAL', code: 'SA-CSS-DIR-002',
      note: `<text> direction="${dirAttr || dirCSS}"; unicode-bidi="${bidiAttr || bidiCSS}"; ` +
            `x="${xVal}"; BCR intersection area=${overX * overY}px²; ` +
            (hasBidi ? 'bidi-override reverses visual char order: Latin text reads backwards (illegible); ' : '') +
            (isRtl && xVal <= 20 ? `RTL + small x: text displaced off-screen left; ` : '') +
            `textContent="${el.textContent.trim().slice(0,30)}..." intact`
    });
  }
  return findings;
}

CRITICAL — SA-CSS-DIR-002: direction="rtl" combined with unicode-bidi="bidi-override" reverses the visual display order of Latin characters, rendering the consent string backwards. Combined with x="0", the text is also displaced off-screen left. Detection must check for the combination of direction="rtl" and unicode-bidi="bidi-override" on any consent text element, regardless of whether the BCR falls within the viewport.

Attack 3: per-element scope — header <text> has direction="ltr" (or default), consent <text> has direction="rtl" x="0" (SA-CSS-DIR-003)

This variant uses per-element direction control: the header element renders left-to-right at x="20" (visible and legible), while the consent text element has direction="rtl" and x="0" (off-screen left). Both elements are independent <text> siblings in the SVG tree; neither inherits direction from the other (SVG direction is not inherited through sibling relationships, only through ancestor grouping). The header demonstrates that the SVG renders text correctly; the any-visible-text heuristic passes on the header element while the consent element is off-screen.

An attacker can make this particularly subtle by using a Unicode bidirectional strong right-to-left character (‏, RIGHT-TO-LEFT MARK) inserted into the text content, which can trigger RTL rendering in some SVG engines without requiring the explicit direction attribute. However, the explicit direction attribute is the most reliable and cross-browser method. Per-element inspection is required: each consent-bearing <text> element must be independently checked for its computed direction value and anchor position.

<!-- SA-CSS-DIR-003: per-element — header direction="ltr" x="20" (visible); -->
<!--                             consent direction="rtl" x="0" (off-screen) -->
<!-- Header renders at x=20 LTR; extends rightward; visible within viewport -->
<!-- Consent at x=0 RTL; extends leftward into x<0; clipped by overflow:hidden -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="140" fill="white"/>

  <!-- header: default direction (ltr); x=20; extends rightward; visible -->
  <text x="20" y="30" font-size="13" fill="#111">Permission Request:</text>

  <!-- consent: direction="rtl" + x="0"; extends leftward from x=0; off-screen left -->
  <!-- Any-visible-text check finds header element and PASSES; per-element check required -->
  <text x="0" y="52" font-size="13" fill="#111" direction="rtl">
    Allow: shell execution, credential access
  </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-element direction + x check; ignore sibling element results
function checkPerElementDirection(svgEl) {
  const findings = [];
  const svgBcr   = svgEl.getBoundingClientRect();
  const svgWidth = svgEl.viewBox?.baseVal?.width || svgEl.clientWidth || 440;

  for (const el of svgEl.querySelectorAll('text')) {
    const content = el.textContent.trim();
    if (!content) continue;

    const dirAttr = el.getAttribute('direction') || '';
    const dirCSS  = getComputedStyle(el).direction || '';
    const isRtl   = dirAttr === 'rtl' || dirCSS === 'rtl';
    if (!isRtl) continue;

    const xVal          = parseFloat(el.getAttribute('x') || '0');
    const elBcr         = el.getBoundingClientRect();
    const textWidth     = elBcr.width > 0 ? elBcr.width : content.length * 7;
    const hiddenPixels  = Math.max(0, textWidth - xVal); // pixels extending past x=0 (left edge)
    const hiddenFraction = hiddenPixels / textWidth;

    if (hiddenFraction > 0.3) {
      const overlapX = Math.max(0,
        Math.min(elBcr.right, svgBcr.right) - Math.max(elBcr.left, svgBcr.left));
      const overlapY = Math.max(0,
        Math.min(elBcr.bottom, svgBcr.bottom) - Math.max(elBcr.top, svgBcr.top));

      findings.push({
        severity: 'HIGH', code: 'SA-CSS-DIR-003',
        note: `<text> direction="rtl"; x="${xVal}"; ` +
              `textWidth≈${textWidth.toFixed(0)}px; ` +
              `hiddenPixels≈${hiddenPixels.toFixed(0)}px (${(hiddenFraction*100).toFixed(0)}% off-screen left); ` +
              `sibling text elements may have direction=ltr and render visibly; ` +
              `any-visible-text check PASSES on sibling; per-element direction check required; ` +
              `BCR intersection area=${overlapX * overlapY}px²; ` +
              `textContent="${content.slice(0,30)}..."`
      });
    }
  }
  return findings;
}

HIGH — SA-CSS-DIR-003: The consent <text> has direction="rtl" and x="0" (off-screen left); the sibling header <text> uses default LTR and is visible. A scanner finding visible text in one element cannot conclude all consent text is visible. Each consent-bearing text element must be independently checked for its computed direction value and anchor x position; RTL text at small x values is a high-confidence off-screen displacement signal.

Attack 4: JS mousedown injects direction="rtl" on consent text, reverts at mouseup (SA-CSS-DIR-004)

The dynamic form of the direction attack sets the consent text element’s direction to RTL during a mouse press. At rest, the text has no direction attribute (default LTR), x="20", and renders normally at x=20 extending rightward — fully visible. A mousedown event listener (capture phase) simultaneously sets direction="rtl" and changes x from "20" to "0". With RTL text and x=0, the text displaces off-screen left. At mouseup, both attributes are restored. The rendering engine repaints immediately on each attribute change; the consent text is invisible during the click window.

The CSS form of this attack sets el.style.direction = ‘rtl’ via the inline style attribute, which takes precedence over the presentation attribute in the CSS cascade for SVG elements. A MutationObserver watching the direction presentation attribute would miss the CSS-style form; the observer must also watch the style attribute and parse it for a direction property. Both the x attribute change and the direction change may be delivered in separate MutationObserver callbacks (consecutive attribute mutations). The detection function should maintain a joint state: if both direction=rtl and a small x value are simultaneously present on a consent element, fire the finding.

<!-- SA-CSS-DIR-004: at rest direction=ltr x=20 (visible); mousedown injects direction=rtl + x=0 -->
<!-- Text jumps off-screen left; reverts at mouseup; static scan finds no direction attr -->
<svg id="consent-svg-dir4" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- at rest: no direction attr (ltr default); x="20"; renders rightward; visible -->
  <!-- mousedown: direction="rtl" + x="0"; text goes off-screen left; clipped -->
  <text id="consent-text-dir4" x="20" y="40" font-size="13" fill="#111">
    Allow: shell execution, credential access
  </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-dir4');
  const text = document.getElementById('consent-text-dir4');
  svg.addEventListener('mousedown', () => {
    text.setAttribute('direction', 'rtl');
    text.setAttribute('x', '0');
    // CSS form (bypasses direction attr observer): text.style.direction = 'rtl';
  }, true);
  svg.addEventListener('mouseup', () => {
    text.removeAttribute('direction');
    text.setAttribute('x', '20');
    // CSS form: text.style.direction = '';
  }, true);
})();
</script>

// Detection: MutationObserver on direction, x, and style attrs; joint-state check
function watchDirectionInject(svgEl) {
  const findings = [];
  const svgBcr   = svgEl.getBoundingClientRect();

  // Track per-element direction and x state from mutations
  const state = new WeakMap(); // el → { direction: string, x: number }

  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const el   = mut.target;
      if (el.tagName !== 'text') continue;

      const attr = mut.attributeName;
      if (!['direction', 'x', 'style'].includes(attr)) continue;

      // Update state
      if (!state.has(el)) state.set(el, { direction: 'ltr', x: 20 });
      const s = state.get(el);

      if (attr === 'direction') s.direction = el.getAttribute('direction') || 'ltr';
      if (attr === 'x')         s.x         = parseFloat(el.getAttribute('x') || '0');
      if (attr === 'style') {
        const styleStr = el.getAttribute('style') || '';
        const match    = styleStr.match(/\bdirection\s*:\s*(\w+)/i);
        if (match) s.direction = match[1];
      }

      // Check joint state: RTL + small x = potential off-screen attack
      if (s.direction === 'rtl' && s.x <= 20) {
        const elBcr    = el.getBoundingClientRect();
        const overlapX = Math.max(0,
          Math.min(elBcr.right,  svgBcr.right)  - Math.max(elBcr.left, svgBcr.left));
        const overlapY = Math.max(0,
          Math.min(elBcr.bottom, svgBcr.bottom) - Math.max(elBcr.top,  svgBcr.top));

        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-DIR-004',
          note: `Dynamic direction="${s.direction}" + x="${s.x}" injected on <text> via "${attr}" mutation; ` +
                `RTL text at x=${s.x} extends off-screen left; ` +
                `BCR intersection area=${overlapX * overlapY}px² (low/zero = off-screen); ` +
                `at rest: no direction attr (ltr; x=20; visible); static checks PASS; ` +
                `MutationObserver attributeFilter=['direction','x','style'] on consent text required; ` +
                `joint state check: direction=rtl AND small x both active simultaneously; ` +
                `synthetic mousedown triggers injection; ` +
                `textContent="${el.textContent?.trim().slice(0,30) ?? ''}..." intact`
        });
      }
    }
  });

  for (const textEl of svgEl.querySelectorAll('text')) {
    observer.observe(textEl, {
      attributes: true,
      attributeFilter: ['direction', 'x', 'style']
    });
  }

  // Synthesize interaction
  svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
  svgEl.dispatchEvent(new MouseEvent('mouseup',   { bubbles: true, cancelable: true }));

  return findings;
}

CRITICAL — SA-CSS-DIR-004: JavaScript simultaneously injects direction="rtl" and x="0" at mousedown, displacing the consent text off-screen left. At rest, the text has no direction attribute and renders at x="20" normally. Detection requires a MutationObserver with attributeFilter: [‘direction’, ‘x’, ‘style’] maintaining a joint state per element; the attack is flagged when both direction=rtl and a small x value are simultaneously active. Synthetic mousedown triggers the injection.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-DIR-001: direction="rtl" + x="0" — text extends off-screen left RTL text anchor at x=0; characters extend leftward; 40-char string × 7px = ~280px; entire text at x∈[-280, 0]; overflow:hidden clips; textContent intact; fill dark textContent; fill color; BCR non-zero; getComputedStyle visibility/display; requires: check direction attr + CSS; for RTL: compute text extent leftward from x; check x ≤ text-width for off-screen signal Critical
SA-CSS-DIR-002: direction="rtl" + unicode-bidi="bidi-override" — reversed char order bidi-override reverses visual display order of Latin chars; "access" renders as "ssecca"; combined with x=0: also off-screen. Double attack: displacement + illegibility textContent (exact string intact); BCR; requires: check unicode-bidi attribute and CSS property; bidi-override on consent text = finding regardless of BCR Critical
SA-CSS-DIR-003: per-element scope — header LTR x=20 (visible); consent RTL x=0 (off-screen) Header: no direction attr (LTR); x=20; visible. Consent: direction=rtl + x=0; off-screen left. Any-visible-text check passes on header textContent; any-visible-text check; requires: per-element direction + x check; RTL + small x on each consent element independently High
SA-CSS-DIR-004: mousedown injects direction=rtl + x=0; reverts mouseup At rest: no direction attr (LTR; x=20; visible); static checks pass. mousedown: direction=rtl + x=0; off-screen. mouseup: revert Static DOM; requires: MutationObserver attributeFilter=['direction','x','style'] + joint-state check (both RTL and small x active simultaneously) + synthetic mousedown Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-DIR-001: SVG direction="rtl" with x="0" on consent text element — the SVG direction presentation attribute sets text to right-to-left rendering; with direction=rtl, the text anchor point at x=0 is the rightmost edge of the text string; all character glyphs extend leftward from x=0 into negative x-coordinate space; for a 40-character consent string at ~7px/char, the text spans approximately x∈[-280, 0]; SVG overflow:hidden (default) clips content at x<0; zero consent pixels are rendered within the viewport; textContent is intact and returns the full consent string; the element’s display is inline, visibility is visible, and fill is a dark color; a scanner checking only these per-element properties passes all checks; detection must read the direction attribute or computed CSS direction property, and for RTL text elements, compute the estimated text extent leftward from the anchor x value to determine whether the text falls within the viewport.
CRITICAL SA-CSS-DIR-002: SVG direction="rtl" combined with unicode-bidi="bidi-override" on consent text — the bidi-override value forces right-to-left character display order regardless of the Unicode bidirectional properties of the characters; Latin characters in the consent string are rendered in reversed visual order (the last character is visually leftmost, the first is visually rightmost); for the string “Allow: shell execution, credential access”, the rendered visual sequence is “ssecca laitnedeRC ,noitucexe llehs :wolA”; this renders as visually illegible text even if the BCR is within the viewport; combined with x=0, the text is also displaced off-screen left providing a double attack (displacement + illegibility); detection must check both direction=rtl and unicode-bidi=bidi-override on consent text elements independently of the BCR position.
HIGH SA-CSS-DIR-003: per-element scope — header text has no direction attribute (default LTR; x=20; renders normally and visibly); consent text has direction="rtl" and x="0" (off-screen left; clipped); both are sibling <text> elements; SVG direction is not inherited between siblings; the header demonstrates that the SVG renders text correctly; any-visible-text heuristic finds the header and produces a false negative for the consent text; per-element inspection is required: for each consent-bearing text element, independently read the direction attribute and computed CSS direction property, and for RTL values, compute the expected text extent leftward from the anchor x coordinate to determine viewport coverage.
CRITICAL SA-CSS-DIR-004: JS mousedown simultaneously injects direction="rtl" and x="0" on the consent text element during click initiation; text anchor moves to x=0 and direction flips to RTL; text displaces off-screen left; clipped by overflow:hidden; at mouseup both attributes revert to their rest values (no direction attr; x="20"); at rest the element renders normally at x=20 LTR and all static checks pass; requires MutationObserver with attributeFilter [‘direction’, ‘x’, ‘style’] on consent text elements; the CSS form of this attack (style.direction=‘rtl’) sets the direction via the style attribute and requires the style attribute to be included in attributeFilter; detection requires joint-state tracking per element: the finding is fired when both direction=rtl and small x value are simultaneously active on a consent element.

Related: SVG tspan x offset consent attacks  |  SVG unicode-bidi consent attacks  |  SVG transform consent attacks

← Blog  |  Security Checklist