Security Guide
MCP server SVG <textPath> consent security — spiral path off-screen, startOffset 80%, invisible off-screen path, and side="right" mirroring
The SVG <textPath> element renders text characters along an arbitrary geometric path rather than on a horizontal baseline. Applied to MCP consent UI, this opens four distinct attack surfaces: the referenced path can be a winding spiral that distributes characters at continuously changing angles; a high startOffset value can cause most of the consent text to begin rendering off the end of the path; the path can begin at an x-coordinate outside the SVG viewport; and the SVG 2 side attribute can invert the text rendering direction on supported browsers. In every case, the textContent of the text element remains intact and a static DOM property read returns no suspicious value — only inspection of the referenced path geometry and the <textPath> attributes reveals the attack.
Attack 1: Consent text placed along an outward spiral path (SA-CSS-TXP-001)
A <path> element defined with a series of cubic Bézier commands can describe an outward Archimedean spiral starting at the center of the consent panel (for example x=200, y=150) and winding outward with each quarter-turn increasing the radius by 30px. When <textPath href="#spiral-path"> references this path, each character is placed tangent to the path at its arc-length position along the spiral. The first few characters near the start of the spiral appear near the center and are roughly horizontal, making them legible. However as the spiral winds outward, the path tangent rotates continuously: at one-quarter of the total arc length the tangent has rotated 90° and characters are rendered reading upward or downward; at half the arc length characters are rendered upside down; at three-quarters they read in the opposite horizontal direction. The full consent string — “This MCP server requests: shell execution, credential access, filesystem write” — is present as a single textContent value and is laid out across the full arc, but no single viewing direction reveals the complete text in readable order. getBoundingClientRect() on the text element returns the axis-aligned bounding box of the entire spiral arc, which may appear within the consent panel bounds; automated audits that check only for visible bounding boxes will report the text as present and on-screen.
<!-- SA-CSS-TXP-001: consent text on outward cubic-bezier spiral
Spiral starts at center (200,150), each C command adds one quarter-turn
with radius growing by ~30px per quarter turn
First chars (near center): ~horizontal, legible
At 90° arc: chars rotated 90° (read upward)
At 180° arc: chars upside down
At 270° arc: chars read rightward but inverted
getBoundingClientRect returns bounding box of full spiral — looks in-bounds
textContent is intact; no static property value reveals the spiral geometry -->
<defs>
<path id="spiral-path"
d="M 200 150
C 230 120, 260 120, 260 150
C 260 200, 200 230, 150 200
C 90 170, 90 100, 150 70
C 210 40, 290 50, 310 120
C 330 200, 290 280, 210 300"
fill="none" stroke="none"/>
</defs>
<text font-size="14" fill="#111">
<textPath href="#spiral-path">This MCP server requests: shell execution, credential access, filesystem write</textPath>
</text>
// Detection: count curve commands in the referenced path
// Many C/Q/A commands indicate a winding or spiral path
function detectSpiralTextPath(svgEl) {
const findings = [];
for (const tp of svgEl.querySelectorAll('textPath')) {
const href = tp.getAttribute('href') || tp.getAttribute('xlink:href') || '';
const pathEl = svgEl.querySelector(href);
if (!pathEl) continue;
const d = pathEl.getAttribute('d') || '';
// Count arc/curve commands — many C/Q/A commands indicate a winding path
const curveCount = (d.match(/[CcQqAa]/g) || []).length;
if (curveCount > 3) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-TXP-001',
note: `<textPath href="${href}"> references path with ${curveCount} curve commands — possible spiral/winding path; consent text characters distributed across path arc at varying angles; complete text unreadable from any single viewing direction`
});
}
}
return findings;
}
CRITICAL — SA-CSS-TXP-001: A <textPath> referencing a path with more than three curve (C, Q, or A) commands is a strong indicator of a winding or spiral geometry that distributes consent text characters at continuously varying angles. textContent is intact, getBoundingClientRect() appears normal, and no static presentation attribute reveals the attack. Detection must query all <textPath> elements, resolve their href to the referenced <path>, extract the d attribute, and count curve commands — flagging any count greater than 3 as CRITICAL.
Attack 2: startOffset="80%" renders only the last 20% of consent text (SA-CSS-TXP-002)
The startOffset attribute on <textPath> specifies the distance along the path’s arc length at which text rendering begins. At startOffset="80%" on a 500px path, text rendering starts at x=400px; the remaining path length is only 100px. For a typical consent string such as “This MCP server requests: shell execution, credential access, filesystem write” (~70 characters at 6px average character advance), the full string requires approximately 420px of path length. With only 100px remaining after the offset (20% of path length), the renderer fits approximately 16 characters — which corresponds to the end of the string, approximately “filesystem write”. The most dangerous phrases — “shell execution” and “credential access” — are never rendered; they exist in the DOM as textContent but fall before the start-offset point and are therefore silently discarded by the SVG layout engine. A user who reads only what appears on screen sees only “filesystem write”, the least alarming fragment of the consent request. getBoundingClientRect() returns the bounding box of the rendered tail, which may appear well within the consent panel bounds.
<!-- SA-CSS-TXP-002: startOffset="80%" silences the dangerous opening of the consent text
Path: M 20 80 L 520 80 (500px horizontal line)
startOffset="80%": rendering begins at x=420 (500 × 0.80)
Remaining path: 100px (500 - 420)
Consent text: "This MCP server requests: shell execution, credential access, filesystem write" (~70 chars × 6px = ~420px)
Characters rendered: only the last ~16 chars that fit in 100px ≈ "filesystem write"
Characters silently dropped: "This MCP server requests: shell execution, credential access, " (~54 chars)
textContent: fully intact; getBoundingClientRect: bounding box of "filesystem write" only -->
<defs>
<path id="linear-path" d="M 20 80 L 520 80" fill="none" stroke="none"/>
</defs>
<text font-size="14" fill="#111">
<textPath href="#linear-path" startOffset="80%">This MCP server requests: shell execution, credential access, filesystem write</textPath>
</text>
// Detection: read startOffset value and flag high offsets
function detectHighStartOffset(svgEl) {
const findings = [];
for (const tp of svgEl.querySelectorAll('textPath')) {
const rawOffset = tp.getAttribute('startOffset') || '0';
// Parse percentage or raw SVG length value
let offsetFraction = 0;
if (rawOffset.endsWith('%')) {
offsetFraction = parseFloat(rawOffset) / 100;
} else {
// Absolute offset: resolve path length to compute fraction
const href = tp.getAttribute('href') || tp.getAttribute('xlink:href') || '';
const pathEl = svgEl.querySelector(href);
if (pathEl && pathEl.getTotalLength) {
const totalLen = pathEl.getTotalLength();
offsetFraction = totalLen > 0 ? parseFloat(rawOffset) / totalLen : 0;
}
}
if (offsetFraction > 0.5) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-TXP-002',
note: `<textPath startOffset="${rawOffset}"> — offset is ${(offsetFraction * 100).toFixed(0)}% of path length; only the last ${(100 - offsetFraction * 100).toFixed(0)}% of path length is available for text rendering; dangerous opening phrases of consent text are never rendered though textContent is intact`
});
} else if (offsetFraction > 0.2) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-TXP-002',
note: `<textPath startOffset="${rawOffset}"> — offset is ${(offsetFraction * 100).toFixed(0)}% of path length; leading consent text may be clipped if path is short relative to text length`
});
}
}
return findings;
}
CRITICAL — SA-CSS-TXP-002: A startOffset value above 50% on a <textPath> ensures that the majority of the consent string is never rendered. The dangerous opening phrases of the consent text fall before the start-offset point and are silently dropped by the SVG renderer while remaining present in textContent. Detection must read the startOffset attribute on every <textPath> element, convert it to a fraction of path length, and flag any value above 50% as CRITICAL. Values between 20% and 50% should be flagged HIGH and cross-checked against the text length and path length to determine how many characters are clipped.
Attack 3: <textPath> references an invisible path starting off-screen at x=500 (SA-CSS-TXP-003)
A <path> element can be defined with its starting coordinate entirely outside the SVG viewport. For example, <path id="off-path" d="M 500 0 L 1500 0"/> starts at x=500 on a 400px-wide SVG and extends to x=1500 — both coordinates are off-screen to the right. The path element itself has no fill and no stroke, so it is visually invisible. When a <textPath href="#off-path"> references this path, text rendering begins at x=500. If the SVG element has overflow: visible (or the default overflow: auto without explicit clipping), the text renders at x=500 — outside the right edge of the consent panel — without any SVG clipping removing it. The text is not visible to the user because it is beyond the right edge of the consent panel element, but it is present in the DOM and passes every textContent-based and getBoundingClientRect()-based audit that does not account for SVG overflow. The path has no visual rendering, so the attack leaves no visible geometry in the consent panel.
<!-- SA-CSS-TXP-003: textPath references a path whose M (moveto) starts at x=500
SVG viewport width: 400px
Path starts at x=500 — 100px beyond the right edge of the SVG viewport
overflow:visible (SVG default unless clipped) means text renders at x=500
Path has no fill, no stroke — invisible; user cannot see it
Text renders entirely off-screen to the right
getBoundingClientRect on text element: x ≈ 500 (outside consent panel)
But if audit only checks textContent or presence in DOM, attack succeeds -->
<svg width="400" height="200" style="overflow:visible">
<defs>
<path id="off-path" d="M 500 0 L 1500 0" fill="none" stroke="none"/>
</defs>
<text font-size="14" fill="#111">
<textPath href="#off-path">This MCP server requests: shell execution, credential access, filesystem write</textPath>
</text>
</svg>
// Detection: parse the M command of the referenced path and check if it starts off-viewport
function detectOffScreenTextPath(svgEl) {
const findings = [];
const svgWidth = parseFloat(svgEl.getAttribute('width') || svgEl.viewBox?.baseVal?.width || 800);
const svgHeight = parseFloat(svgEl.getAttribute('height') || svgEl.viewBox?.baseVal?.height || 600);
for (const tp of svgEl.querySelectorAll('textPath')) {
const href = tp.getAttribute('href') || tp.getAttribute('xlink:href') || '';
const pathEl = svgEl.querySelector(href);
if (!pathEl) continue;
const d = pathEl.getAttribute('d') || '';
// Extract first M/m command coordinates
const mMatch = d.match(/^[Mm]\s*([-\d.]+)[,\s]+([-\d.]+)/);
if (!mMatch) continue;
const startX = parseFloat(mMatch[1]);
const startY = parseFloat(mMatch[2]);
if (startX > svgWidth || startX < 0 || startY > svgHeight || startY < 0) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-TXP-003',
note: `<textPath href="${href}"> references path whose M command starts at (${startX}, ${startY}) — outside SVG viewport (${svgWidth}×${svgHeight}); text rendering begins off-screen; SVG overflow:visible means no clipping occurs; consent text invisible to user but present in DOM`
});
}
}
return findings;
}
CRITICAL — SA-CSS-TXP-003: A <textPath> referencing a path whose initial M command places the starting point outside the SVG viewport causes all text rendering to occur off-screen. The path itself is invisible (no fill/stroke) and leaves no visible artifact. Because SVG overflow: visible is the default in many contexts, no automatic clipping removes the text. Detection must parse the d attribute of every path referenced by a <textPath>, extract the first M or m command’s coordinates, compare them against the SVG viewport dimensions, and flag any path that starts outside the viewport as CRITICAL.
Attack 4: side="right" SVG 2 attribute inverts text orientation (SA-CSS-TXP-004)
SVG 2 introduces the side attribute on <textPath> with two values: side="left" (the default, placing text on the left side of the path direction, i.e., above a left-to-right horizontal path) and side="right" (placing text on the right side of the path direction, i.e., below a left-to-right horizontal path with a flipped baseline). For a left-to-right horizontal path, side="right" renders the text below the path with each glyph’s baseline mirrored — characters appear upside down relative to the normal reading orientation. On a circular path used for circular consent text layout (a common decorative technique), side="right" renders text on the inside of the circle rather than the outside: characters appear mirrored and the text reads counter-clockwise instead of clockwise, making it illegible to a typical reader. Browser support is limited but growing: Chrome 97+ and Firefox 112+ implement side="right"; on unsupported browsers the attribute is silently ignored and text renders normally (the attack degrades gracefully for attackers — older browsers show readable text, newer browsers show inverted text). The side attribute does not appear in any CSSOM property and is not inspected by standard accessibility or contrast audits.
<!-- SA-CSS-TXP-004: side="right" inverts text on supported browsers (Chrome 97+, Firefox 112+)
On a horizontal left-to-right path:
side="left" (default): text above path, right-reading, normal orientation
side="right": text below path, baseline mirrored, characters appear upside down
On a circular path (r=80):
side="left": text on outside of circle, reads clockwise
side="right": text on inside of circle, appears mirrored, reads counter-clockwise
On unsupported browsers: attribute ignored, text renders normally (attack degrades gracefully)
No CSSOM property exposes the side attribute value -->
<!-- Example A: horizontal path -->
<defs>
<path id="h-path" d="M 20 100 L 380 100" fill="none" stroke="none"/>
</defs>
<text font-size="14" fill="#111">
<textPath href="#h-path" side="right">Shell execution and credential access requested</textPath>
</text>
<!-- Example B: circular path (text reads mirrored/counter-clockwise inside circle) -->
<defs>
<path id="circle-path"
d="M 200 70 A 80 80 0 1 1 199.9 70"
fill="none" stroke="none"/>
</defs>
<text font-size="14" fill="#111">
<textPath href="#circle-path" side="right">This MCP server requests shell execution and credential access</textPath>
</text>
// Detection: flag any textPath with side="right"
function detectSideRight(svgEl) {
const findings = [];
for (const tp of svgEl.querySelectorAll('textPath')) {
const side = tp.getAttribute('side');
if (side === 'right') {
const href = tp.getAttribute('href') || tp.getAttribute('xlink:href') || '';
findings.push({
severity: 'HIGH', code: 'SA-CSS-TXP-004',
note: `<textPath href="${href}" side="right"> — SVG 2 side attribute places text on the right side of path direction; on horizontal paths this inverts character baselines (upside down); on circular paths text is mirrored and reads counter-clockwise; Chrome 97+/Firefox 112+ support; unsupported browsers silently ignore side attribute`
});
}
}
return findings;
}
HIGH — SA-CSS-TXP-004: The SVG 2 side="right" attribute on <textPath> causes text rendering to be mirrored and inverted on supported browsers (Chrome 97+, Firefox 112+). On circular paths the text reads counter-clockwise on the inside of the circle, making it illegible. The attack is browser-dependent: older browsers silently ignore the attribute, so the attack targets only users on modern browsers. Severity is HIGH rather than CRITICAL because the effect is not universally applied. Detection must flag every <textPath side="right"> and note the browser-support boundary.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
| SA-CSS-TXP-001: spiral path distributes chars at varying angles | Path defined with 4+ cubic-bezier C commands winds outward as a spiral; textPath distributes characters tangent to path at each arc-length position; chars at 90°/180°/270° of arc are rotated 90°/180°/270°; getBoundingClientRect returns full spiral bounding box; textContent intact; no static presentation attribute reveals the geometry | Full consent text (geometric — complete string is present in DOM but no single viewing direction reveals it in reading order); bounding box appears in-bounds; only path d-attribute curve-command count reveals the attack | Critical |
| SA-CSS-TXP-002: startOffset=80% clips the dangerous opening phrases | startOffset="80%" on a 500px path begins text rendering at x=400; remaining 100px fits ~16 chars; consent text "This MCP server requests: shell execution, credential access, filesystem write" (~70 chars × 6px ≈ 420px) has its first ~54 chars silently dropped; only "filesystem write" visible; textContent fully intact; getBoundingClientRect returns bounding box of visible tail | The most dangerous consent phrases ("shell execution", "credential access") — never rendered, silent drop; "filesystem write" displayed (least alarming fragment); only startOffset attribute value reveals the attack | Critical |
| SA-CSS-TXP-003: path M command starts off-screen at x=500 | path id="off-path" d="M 500 0 L 1500 0" — path starts at x=500 on a 400px SVG; no fill, no stroke (invisible); SVG overflow:visible; textPath href="#off-path" renders text starting at x=500; text exits the consent panel boundary to the right; getBoundingClientRect on text element: x≈500; textContent intact | All consent text (positional — rendered entirely outside the SVG viewport); path itself is invisible; overflow:visible means no SVG clipping; only first-M-coordinate comparison against viewport dimensions reveals the attack | Critical |
| SA-CSS-TXP-004: side="right" mirrors and inverts text | SVG 2 side="right" attribute on textPath places text on right side of path direction; horizontal path: glyphs rendered upside down; circular path: text mirrored, reads counter-clockwise on inside of circle; Chrome 97+/Firefox 112+ support; unsupported browsers ignore attribute (attack degrades gracefully); no CSSOM property exposes side value | All consent text on supported browsers (geometric inversion — characters upside down or mirrored); browser-dependent severity; textContent intact; only side attribute inspection reveals the attack; unsupported browsers unaffected | High |
Defences
- Enumerate all
<textPath>elements and resolve their referenced paths — the attack surface of<textPath>is entirely in the combination of the path geometry and the<textPath>attributes (startOffset,side); audits that check onlytextContentor computed CSS properties miss all four attack vectors; every<textPath>in consent-bearing SVG must be inspected. - Count curve commands in referenced path
dattributes and flag counts above 3 as CRITICAL — a path with more than threeC,Q, orAcommands cannot be a simple straight or gently curved line; such paths are candidates for winding or spiral geometries that distribute consent text characters at varying angles unreadable from any single direction. - Read and normalize
startOffsetas a fraction of path arc length and flag values above 50% as CRITICAL — convert percentage values directly; convert absolute values by dividing by the path’sgetTotalLength(); values above 50% guarantee that the majority of the consent text is never rendered; values between 20% and 50% warrant cross-checking against the ratio of text length to path length. - Parse the first
Mcommand of the referenced path and compare against the SVG viewport dimensions — the viewport width and height are available from the SVG element’swidth/heightattributes orviewBox; any path whose starting coordinate is outside the viewport (x < 0, x > width, y < 0, y > height) places all text off-screen; flag as CRITICAL. - Flag
<textPath side="right">as HIGH regardless of path geometry — thesideattribute is always suspicious on consent text because its only legitimate use in consent UI would be a deliberate design choice to invert the text direction, which is not a valid consent presentation; note the browser-support boundary (Chrome 97+, Firefox 112+) in the finding.