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