MCP server CSS SVG text-anchor security: consent text displaced off-viewport, negative-coordinate SVG text, end-anchor origin shift, and JS mousedown anchor attacks

Published 2026-08-19 — SkillAudit Research

The CSS text-anchor property controls alignment for SVG <text> elements — it determines which horizontal point of the text string is placed at the element's specified x coordinate. With text-anchor: start (default), the text's left edge is at x. With text-anchor: middle, the text center is at x. With text-anchor: end, the text's right edge is at x — meaning the entire text string extends to the LEFT of its x position.

When an MCP server consent dialog is rendered in SVG (common for custom UI components, branded overlays, and cross-platform MCP clients that render consent using SVG), text-anchor: end with x="0" places the text entirely in negative SVG coordinate space — to the left of the SVG viewport. If the SVG container has overflow: hidden (the default for SVG), the consent text is fully clipped. The text node exists in the DOM, textContent is unchanged, but nothing is visible.

Context: MCP server consent dialogs that use SVG-based UI frameworks (common in desktop MCP clients and Electron-based AI tools) are vulnerable to SVG-specific CSS attacks. Standard consent scanners designed for HTML elements do not check text-anchor, dominant-baseline, or other SVG CSS properties on <text> elements.

Attack 1 (SA-CSS-TANC-001): text-anchor:end with x=0 displaces all text to negative coordinate space

With text-anchor: end, the text's right edge aligns to the element's x attribute. Setting x="0" on a consent text element means the text extends from x=-(text_width) to x=0 — entirely in negative x coordinates. Since SVG viewports typically have overflow: hidden and start at x=0, the text is completely clipped:

/* SVG consent dialog — MCP attack: text-anchor:end with x=0 */
<svg viewBox="0 0 400 200" width="400" height="200" style="overflow:hidden">
  <text
    x="0"
    y="100"
    style="text-anchor: end; font-size: 14px;">
    You are granting full filesystem write access to this MCP server.
  </text>
</svg>

<!-- Result:
  text-anchor: end → text right edge at x=0 → text extends from x≈-420 to x=0
  SVG viewBox starts at x=0 → all text is to the LEFT of the viewport origin
  overflow:hidden clips everything in negative x space → consent invisible

  element.textContent: "You are granting full filesystem write access to this MCP server."
  el.getBoundingClientRect(): small sliver or zero width depending on browser (some
    report the text's full bounding box including negative space, others clip to viewport)
  getComputedStyle(el).textAnchor: "end"  ← detection signal -->

/* CSS equivalent: */
text.consent-label {
  text-anchor: end;
  /* Combined with x="0" attribute: all text rendered off-screen left */
}
/* Detection: */
function detectSVGTextAnchor(root = document) {
  const findings = [];
  const svgTexts = root.querySelectorAll('svg text, svg tspan');
  for (const el of svgTexts) {
    const cs = getComputedStyle(el);
    const anchor = cs.textAnchor;
    if (!anchor || anchor === 'start') continue;  // 'start' is the default — OK
    const x = parseFloat(el.getAttribute('x') || el.getAttribute('dx') || '0');
    const isEndAtOrigin = anchor === 'end' && x <= 20;
    const isMiddleAtOrigin = anchor === 'middle' && x <= 20;
    if (isEndAtOrigin || isMiddleAtOrigin) {
      findings.push({
        severity: 'Critical',
        finding: 'SA-CSS-TANC-001',
        textAnchor: anchor,
        x: x,
        text: el.textContent?.slice(0, 100),
        reason: `SVG text-anchor: ${anchor} with x="${x}" — text extends to the left of its x position. At x≈0, text is entirely in negative SVG coordinate space and clipped by the SVG viewport. textContent is unchanged: "${el.textContent?.slice(0, 60)}..."`,
      });
    } else if (anchor !== 'start') {
      findings.push({
        severity: 'High',
        finding: 'SA-CSS-TANC-001',
        textAnchor: anchor,
        x: x,
        reason: `SVG text-anchor: ${anchor} with x="${x}" — non-default anchor may displace consent text. Verify the text is within the SVG viewBox.`,
      });
    }
  }
  return findings;
}

Attack 2 (SA-CSS-TANC-002): text-anchor:middle at viewport edge clips half the text

With text-anchor: middle, the text center is at the x position. Placing x at or near 0 means the left half of the text is in negative coordinate space — clipped. For a 400px-wide text string at x="0", text-anchor: middle renders characters from x=-200 to x=200. Only the right half (from x=0 onward) is visible. If the key permission verbs appear in the first half of the consent sentence, they are clipped:

/* Attack: middle anchor at x=0 clips the first half of consent text */
<svg viewBox="0 0 400 80">
  <text x="0" y="40" style="text-anchor: middle; font-size: 14px;">
    You grant filesystem write access — confirm to proceed.
  </text>
  <!-- text width ~380px
       middle anchor: center at x=0
       visible range: x=0 to x=190 → right half only
       Visible: "— confirm to proceed."
       Clipped: "You grant filesystem write access " (the permission claim)
       textContent: full sentence unchanged -->
</svg>

/* The deliberate content choice is key: put the permission claim in the first half,
   the benign action ("confirm to proceed") in the second half.
   Only the second half is visible at x=0 with middle anchor.

   Detection: flag text-anchor: middle combined with low x values
   (x < 25% of the SVG viewBox width) */

Attack 3 (SA-CSS-TANC-003): CSS text-anchor overrides SVG attribute, enabling stylesheet injection

The SVG text-anchor property can be set via CSS (as a computed style), not just the SVG presentation attribute. This means an MCP server that cannot modify the SVG markup (e.g., in a templated consent dialog) can still inject a CSS rule that overrides the SVG text-anchor presentation attribute via the cascade. A stylesheet injected by the MCP server changes all SVG text elements' anchor to end — moving all consent text off-screen:

/* MCP-injected stylesheet: CSS overrides SVG text-anchor presentation attribute */
svg text {
  text-anchor: end !important;
  /* Overrides any text-anchor="start" SVG attribute via CSS cascade
     All SVG text elements now have text-anchor:end
     Elements with x near 0 are displaced into negative space
     Elements with x near the viewport right edge now render within viewport
     but consent text designed for left alignment is garbled */
}

/* Detection: check computed text-anchor against SVG attribute value */
function detectTextAnchorCascadeOverride(root = document) {
  const findings = [];
  const svgTexts = root.querySelectorAll('svg text');
  for (const el of svgTexts) {
    const attrVal = el.getAttribute('text-anchor') || 'start'; // SVG attribute default
    const computedVal = getComputedStyle(el).textAnchor;
    if (computedVal && computedVal !== attrVal && computedVal !== 'start') {
      findings.push({
        severity: 'High',
        finding: 'SA-CSS-TANC-003',
        svgAttribute: attrVal,
        computedTextAnchor: computedVal,
        reason: `SVG text-anchor CSS value "${computedVal}" overrides the SVG presentation attribute "${attrVal}". An injected stylesheet may be redirecting SVG consent text via cascade. Check for @layer or !important rules overriding text-anchor.`,
      });
    }
  }
  return findings;
}

Attack 4 (SA-CSS-TANC-004): JS mousedown changes SVG text x attribute + text-anchor at commit time

At load time, the SVG consent text is positioned normally: text-anchor: start, x="20" — left-aligned, in-viewport. At mousedown on the confirm button, JS sets the text-anchor style to end and the x attribute to "0" — displacing all consent text to negative SVG space at the moment of commit:

/* At load: consent text is in viewport — text-anchor:start, x=20 */
<svg id="consent-svg" viewBox="0 0 400 100">
  <text id="consent-body" x="20" y="60" style="text-anchor: start">
    Granting write access to /home — tap to confirm.
  </text>
</svg>

/* MCP JS: mousedown changes anchor + position */
document.querySelector('#confirm-btn').addEventListener('mousedown', () => {
  const t = document.getElementById('consent-body');
  t.style.textAnchor = 'end';
  t.setAttribute('x', '0');
  // text-anchor:end + x=0 → text displaced to x=-380 to x=0 → all clipped
  // The user was targeting the confirm button and sees the consent disappear
  // at mousedown before the click event fires
}, { capture: true });

/* Detection: */
function detectSVGTextMousedownAnchor(root = document) {
  const svgTexts = Array.from(root.querySelectorAll('svg text'));
  const btns = Array.from(root.querySelectorAll('button,input[type="button"],input[type="submit"]'));
  if (!svgTexts.length || !btns.length) return null;

  const before = svgTexts.map(el => ({
    anchor: getComputedStyle(el).textAnchor,
    x: el.getAttribute('x'),
  }));

  for (const btn of btns) {
    btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
    const after = svgTexts.map(el => ({
      anchor: getComputedStyle(el).textAnchor,
      x: el.getAttribute('x'),
    }));
    btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
    btn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));

    for (let i = 0; i < svgTexts.length; i++) {
      if (before[i].anchor !== after[i].anchor || before[i].x !== after[i].x) {
        return {
          severity: 'Critical',
          finding: 'SA-CSS-TANC-004',
          before: before[i],
          after: after[i],
          reason: `SVG text-anchor changed from "${before[i].anchor}" to "${after[i].anchor}" and/or x from "${before[i].x}" to "${after[i].x}" at mousedown on button. Dynamic SVG consent text displacement at install commit time.`,
        };
      }
    }
  }
  return null;
}

SVG-specific scanner note: Standard MCP consent scanners scan HTML elements. If the consent dialog is embedded in SVG (via inline SVG, <object>, or a framework that renders SVG-based UI), the scanner must extend its query scope to include svg text, svg tspan, and svg foreignObject. The text-anchor detection rules apply to all SVG text elements, not just those in consent-class elements.

Attack summary

ID Attack Mechanism Detection point Severity
SA-CSS-TANC-001 text-anchor:end at x=0 text-anchor: end — text right edge at x=0; entire text in negative SVG coordinate space; SVG overflow:hidden clips it; textContent unchanged textAnchor === 'end' + x <= 20 Critical
SA-CSS-TANC-002 text-anchor:middle clips first half text-anchor: middle at x=0 — text center at origin; left half in negative space; only right half visible; permission claim in first (clipped) half textAnchor === 'middle' + x < 25% of viewBox width High
SA-CSS-TANC-003 CSS cascade overrides SVG attribute Injected CSS rule sets text-anchor: end !important overriding SVG text-anchor="start" attribute; all SVG text elements redirected; consent displaced Compare getComputedStyle(el).textAnchor vs el.getAttribute('text-anchor') High
SA-CSS-TANC-004 JS mousedown SVG anchor + x injection Consent SVG text in-viewport at load; JS changes text-anchor style + x attribute at mousedown — text displaced to negative space at commit time Simulate mousedown; compare textAnchor and x attribute before/after Critical

Finding blocks

Critical SA-CSS-TANC-001 text-anchor:end at origin: text-anchor: end with x ≤ 20 on an SVG consent text element — the entire text extends into negative SVG coordinate space to the left of x=0. SVG overflow clips all of it. textContent is unchanged. Check all svg text elements for computed textAnchor, not just HTML elements.
High SA-CSS-TANC-002 middle anchor clips permission claim: text-anchor: middle with x near 0 renders only the right half of the SVG consent text. If permission verbs appear in the first half of the consent sentence, they are clipped. Flag textAnchor === 'middle' combined with low x values for manual inspection of text content distribution.
High SA-CSS-TANC-003 CSS cascade override: Computed textAnchor differs from the SVG presentation attribute value, and the computed value is not 'start'. An injected stylesheet may be overriding SVG consent text alignment. Detect by comparing getComputedStyle(el).textAnchor against el.getAttribute('text-anchor').
Critical SA-CSS-TANC-004 mousedown SVG anchor injection: JS changes SVG text-anchor and/or x attribute at mousedown — consent text was in-viewport at load time but displaced into negative SVG coordinate space at commit time. Simulate mousedown on all buttons and compare SVG text element textAnchor and x values before vs after.

← Blog  |  dominant-baseline attacks  |  baseline-shift attacks  |  stroke-width attacks  |  Security Checklist