Security Guide

MCP server SVG <desc> element consent security — misleading accessible description, aria-label mismatch, Unicode RTL injection, and duplicate <desc> ambiguity

The SVG <desc> element provides the accessible description for an SVG element — it is announced by screen readers as supplementary context after the accessible name. Visual inspection of a consent panel may show correct permission text while the <desc> element contains entirely different instructions that assistive technology users hear. Four attack patterns exploit SVG <desc> and related accessibility attributes to cause screen reader users to grant permissions they did not intend to grant.

Attack 1: Misleading <desc> conflicts with visual consent text (SA-CSS-DES-001)

The SVG <desc> element is placed as the first child of the consent SVG element. Screen readers announce it as the accessible description — supplementary information after the accessible name. An attacker sets the <desc> text to a description that minimizes or misrepresents the actual permissions being requested, while the visible SVG text elements display the full (more alarming) permission list. Screen reader users hear the benign description and approve; sighted users see the full list but may not be the target. Because <desc> is a standard SVG element with no visual rendering, it is invisible to visual inspection and DOM tree views that collapse metadata elements. An audit checking textContent of the consent SVG root returns the concatenation of all descendant text nodes including <desc> — which looks legitimate when both the description and the visible text are included. The conflict only becomes apparent when <desc> text is compared separately against the visible text elements.

<!-- SA-CSS-DES-001: misleading <desc> provides different description to screen readers -->
<svg role="dialog" aria-labelledby="consent-title" width="400" height="200">
  <!-- desc: screen reader announces this as the accessible description -->
  <desc>This tool requests read-only access to help you with your task.</desc>

  <text id="consent-title" x="20" y="30" font-size="16" font-weight="bold">
    Permission Request
  </text>
  <!-- Visual text shows the real permissions -- screen reader may skip these
       text elements in favor of the <desc> supplementary description -->
  <text x="20" y="60" font-size="13">Allow: shell execution, credential access,</text>
  <text x="20" y="80" font-size="13">filesystem write, network egress, env var read</text>
  <text x="20" y="110" font-size="13">[Allow]           [Deny]</text>
</svg>

// Detection: extract <desc> text and compare against visible text elements
function checkDescVsVisibleText(svgEl) {
  const descEl = svgEl.querySelector(':scope > desc');
  if (!descEl) return [];

  const descText = descEl.textContent.trim();
  const visibleTextEls = svgEl.querySelectorAll('text, tspan, textPath');
  const visibleText = Array.from(visibleTextEls)
    .map(el => el.textContent.trim())
    .filter(t => t.length > 0)
    .join(' ');

  const findings = [];

  // Flag if desc text omits high-severity permission keywords present in visible text
  const sensitiveTerms = [
    'shell', 'exec', 'credential', 'password', 'token', 'key',
    'filesystem', 'write', 'delete', 'network', 'egress', 'env'
  ];
  for (const term of sensitiveTerms) {
    if (visibleText.toLowerCase().includes(term) &&
        !descText.toLowerCase().includes(term)) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-DES-001',
        note: `<desc> omits sensitive permission term "${term}" that appears in visible SVG text — ` +
              `screen readers may announce <desc> as the accessible description, causing assistive ` +
              `technology users to hear a description that understates permission scope`
      });
    }
  }

  // Also flag if desc and visible text are substantially different in length
  if (descText.length > 10 && visibleText.length / descText.length > 3) {
    findings.push({
      severity: 'HIGH', code: 'SA-CSS-DES-001',
      note: `<desc> text is substantially shorter than visible SVG text (${descText.length} vs ` +
            `${visibleText.length} chars) — abbreviated description may omit critical permission scope`
    });
  }

  return findings;
}

CRITICAL — SA-CSS-DES-001: A <desc> element that omits sensitive permission terms present in the visible SVG text creates a split-reality consent experience: screen reader users hear an understated description while sighted users see the full permission list. Detection must extract <desc> text separately from visible text elements and compare them for omitted sensitive terms. The textContent of the SVG root concatenates all text including <desc> and does not expose the conflict.

Attack 2: aria-label on consent button group announces false confirmation (SA-CSS-DES-002)

An aria-label attribute on an SVG <g> element containing the “Deny” button overrides all descendant text content for screen readers. The aria-label value is “Confirm and allow all requested permissions” while the visible <text> child inside the group reads “Deny”. Screen reader users navigating the consent dialog by tab key reach the group, hear “Confirm and allow all requested permissions” as the accessible name, and click what they believe is the confirm button — which is actually the Deny button. The interaction is inverted. Because aria-label is a standard ARIA attribute on a <g> element (not on the SVG root), it does not appear in visual inspection and is not flagged by accessibility checkers that focus on the SVG root label. The attack is particularly effective because the aria-label value sounds like confirmation, matching what a user expects to hear when approving a permission request.

<!-- SA-CSS-DES-002: aria-label on Deny button group announces false "Allow" text -->
<svg width="400" height="200" role="dialog" aria-label="Permission Request">
  <text x="20" y="40" font-size="14">Allow shell execution?</text>

  <!-- Allow button: correctly labeled -->
  <g role="button" tabindex="0" aria-label="Allow" onclick="grant()">
    <rect x="20" y="60" width="80" height="30" rx="4" fill="#22c55e"/>
    <text x="60" y="80" text-anchor="middle" fill="white" font-size="13">Allow</text>
  </g>

  <!-- Deny button: aria-label says "Confirm and allow" -- inverts screen reader user's action -->
  <g role="button" tabindex="0"
     aria-label="Confirm and allow all requested permissions"
     onclick="deny()">
    <rect x="120" y="60" width="80" height="30" rx="4" fill="#ef4444"/>
    <text x="160" y="80" text-anchor="middle" fill="white" font-size="13">Deny</text>
  </g>
</svg>

// Detection: compare aria-label against descendant text content for all interactive elements
function checkAriaLabelMismatch(svgEl) {
  const findings = [];
  const interactiveEls = svgEl.querySelectorAll('[role="button"], [tabindex], button');

  for (const el of interactiveEls) {
    const ariaLabel = el.getAttribute('aria-label');
    if (!ariaLabel) continue;

    const visualText = Array.from(el.querySelectorAll('text, tspan'))
      .map(t => t.textContent.trim())
      .filter(t => t.length > 0)
      .join(' ')
      .toLowerCase();

    const ariaLabelLower = ariaLabel.toLowerCase();

    // Deny button with aria-label containing allow/confirm/grant terms
    const denyTerms = ['deny', 'cancel', 'decline', 'reject'];
    const allowTerms = ['allow', 'confirm', 'grant', 'approve', 'accept'];

    const visualIsDeny = denyTerms.some(t => visualText.includes(t));
    const ariaIsAllow = allowTerms.some(t => ariaLabelLower.includes(t));

    if (visualIsDeny && ariaIsAllow) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-DES-002',
        note: `Interactive element has visual text "${visualText}" (deny-type) but aria-label ` +
              `"${ariaLabel}" (allow-type) — screen reader users hear confirmation while clicking Deny; ` +
              `assistive technology users unintentionally reject consent they intended to grant`
      });
    }

    // Also flag Allow button with deny-type aria-label (reverse attack)
    const visualIsAllow = allowTerms.some(t => visualText.includes(t));
    const ariaIsDeny = denyTerms.some(t => ariaLabelLower.includes(t));
    if (visualIsAllow && ariaIsDeny) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-DES-002',
        note: `Interactive element has visual text "${visualText}" (allow-type) but aria-label ` +
              `"${ariaLabel}" (deny-type) — screen reader users hear denial while clicking Allow`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-DES-002: An aria-label on an interactive SVG group element overrides all descendant text for screen readers. Placing allow-type text in the aria-label of a deny button (or vice versa) causes screen reader users to click the wrong button. Detection must compare aria-label semantic intent against the visual text content of the element’s <text> children and flag allow/deny inversions as CRITICAL.

Attack 3: Unicode right-to-left override characters in <desc> text (SA-CSS-DES-003)

Unicode includes bidirectional text control characters that can reverse the reading order of a string. The right-to-left override character (U+202E, RLO) causes all subsequent characters to be rendered right-to-left until a PDF (U+202C) terminates the override. In a <desc> element, an attacker embeds RLO before the permission names so that screen readers announce the permissions in reversed order — transforming “shell execution credential access” into “ssecca laitnederc noitucexe llehs” when read character-by-character, or causing bidirectional text engines to reassemble the characters as a different phrase. The DOM textContent property returns the raw code points including the invisible control characters; a DOM inspector or console.log display may show the visually-correct string because terminals and browsers apply the same RTL override. Detection requires scanning the <desc> text for the specific Unicode control characters: U+202E (RLO), U+202D (LRO), U+200F (RLM), U+202B (RLE), and the newer U+2067 (RLI) / U+2069 (PDI).

<!-- SA-CSS-DES-003: Unicode RLO in <desc> reverses permission name reading order -->
<svg role="dialog" aria-describedby="consent-desc" width="400" height="200">
  <!-- The <desc> contains invisible U+202E (RLO) before permission names.
       DOM textContent looks like: "Grants access to ‮noitucexe llehs‬ only"
       Screen reader reads reversed characters. DOM inspector shows reversed text visually. -->
  <desc id="consent-desc">Grants access to ‮shell execution‬ and ‮credentials‬</desc>

  <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
  <text x="20" y="70" font-size="13">[Allow]  [Deny]</text>
</svg>

// Detection: scan <desc>, aria-label, and visible text for Unicode bidi control characters
const BIDI_CONTROL_CHARS = [
  '‮', // RIGHT-TO-LEFT OVERRIDE (RLO)
  '‭', // LEFT-TO-RIGHT OVERRIDE (LRO)
  '‫', // RIGHT-TO-LEFT EMBEDDING (RLE)
  '‪', // LEFT-TO-RIGHT EMBEDDING (LRE)
  '‏', // RIGHT-TO-LEFT MARK (RLM)
  '⁧', // RIGHT-TO-LEFT ISOLATE (RLI)
  '⁦', // LEFT-TO-RIGHT ISOLATE (LRI)
  '⁩', // POP DIRECTIONAL ISOLATE (PDI)
  '‬', // POP DIRECTIONAL FORMATTING (PDF)
];

function checkBidiInAccessibilityText(svgEl) {
  const findings = [];

  // Check <desc> elements
  for (const desc of svgEl.querySelectorAll('desc')) {
    const text = desc.textContent;
    const found = BIDI_CONTROL_CHARS.filter(ch => text.includes(ch));
    if (found.length > 0) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-DES-003',
        note: `<desc> element contains ${found.length} Unicode bidi control character(s) ` +
              `(codepoints: ${found.map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4,'0')).join(', ')}) — ` +
              `permission names may be reversed in screen reader announcement; textContent appears ` +
              `correct in DOM inspector due to bidi rendering`
      });
    }
  }

  // Check aria-label and aria-describedby target elements
  for (const el of svgEl.querySelectorAll('[aria-label], [aria-describedby]')) {
    const label = el.getAttribute('aria-label') || '';
    const describedById = el.getAttribute('aria-describedby');
    const describedByEl = describedById ? svgEl.getElementById(describedById) : null;
    const describedByText = describedByEl ? describedByEl.textContent : '';

    for (const text of [label, describedByText]) {
      const found = BIDI_CONTROL_CHARS.filter(ch => text.includes(ch));
      if (found.length > 0) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-DES-003',
          note: `ARIA accessibility text contains Unicode bidi override characters — ` +
                `announced text may differ from what DOM inspection shows`
        });
      }
    }
  }

  return findings;
}

CRITICAL — SA-CSS-DES-003: Unicode bidirectional control characters embedded in <desc> or aria-label text can reverse the order in which screen readers announce permission names. These characters are invisible in both the rendered page and in DOM inspectors that apply the same bidi rendering. Detection must scan the raw code points of all accessibility text strings for U+202E, U+202D, U+202B, U+200F, and related bidi control characters.

Attack 4: Duplicate <desc> siblings produce undefined screen reader behavior (SA-CSS-DES-004)

The SVG specification states that an element should have at most one <desc> child. When multiple <desc> elements are present, browser accessibility tree construction behavior is undefined: some browsers announce the first <desc>, others the last, others concatenate them, and some ignore all but one. An attacker exploits this ambiguity by placing a legitimate first <desc> (for accessible compliance), then a misleading second <desc> with minimized permission scope. In Chrome’s accessibility tree (as of 2026), the last <desc> in document order takes precedence; in Firefox, the first wins. The attacker targets Chrome users (majority browser share) with a second <desc> that understates permissions. Accessibility audit tools that check for the presence of a <desc> element pass the check (one exists) without flagging the duplicate. Detection must count <desc> child elements and flag any SVG consent element with more than one.

<!-- SA-CSS-DES-004: duplicate <desc> elements -- undefined browser behavior exploited -->
<svg role="dialog" width="400" height="200">
  <!-- First <desc>: legitimate description for compliance checkers -->
  <desc>MCP tool permission request requiring shell execution, credential access,
    filesystem write permissions, and network egress.</desc>

  <!-- Second <desc>: minimized description -- Chrome announces this one (last-wins) -->
  <desc>A helpful tool requesting basic file access.</desc>

  <text x="20" y="40" font-size="14">
    Allow: shell execution, credential access, filesystem write, network egress
  </text>
  <text x="20" y="80" font-size="13">[Allow]  [Deny]</text>
</svg>

// Detection: flag any consent SVG element with multiple <desc> children
function checkDuplicateDesc(svgEl) {
  const findings = [];

  // Check root SVG element
  const rootDescs = Array.from(svgEl.children).filter(
    el => el.tagName.toLowerCase() === 'desc'
  );
  if (rootDescs.length > 1) {
    findings.push({
      severity: 'HIGH', code: 'SA-CSS-DES-004',
      note: `SVG root element has ${rootDescs.length} <desc> children — spec allows only one; ` +
            `browser behavior is undefined (Chrome announces last, Firefox announces first); ` +
            `descriptions: "${rootDescs.map(d => d.textContent.trim().slice(0, 60)).join('" / "')}"`,
      descriptions: rootDescs.map(d => d.textContent.trim())
    });
  }

  // Check nested groups with role=dialog or role=group
  for (const el of svgEl.querySelectorAll('[role]')) {
    const descChildren = Array.from(el.children).filter(
      ch => ch.tagName.toLowerCase() === 'desc'
    );
    if (descChildren.length > 1) {
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-DES-004',
        note: `SVG element with role="${el.getAttribute('role')}" has ${descChildren.length} <desc> children — ` +
              `undefined screen reader behavior; attacker can control which description is announced ` +
              `per-browser by controlling desc order`
      });
    }
  }

  // Also flag when first <desc> content differs substantially from visible text
  if (rootDescs.length >= 1) {
    const firstDesc = rootDescs[0].textContent.trim();
    const visibleText = Array.from(svgEl.querySelectorAll('text, tspan'))
      .map(t => t.textContent.trim()).join(' ');

    const sensitiveTerms = ['shell', 'exec', 'credential', 'write', 'network', 'egress', 'delete'];
    const omitted = sensitiveTerms.filter(
      term => visibleText.toLowerCase().includes(term) &&
              !firstDesc.toLowerCase().includes(term)
    );
    if (omitted.length >= 2) {
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-DES-001',
        note: `First <desc> omits ${omitted.length} sensitive permission term(s) (${omitted.join(', ')}) ` +
              `present in visible SVG text — cross-reference as SA-CSS-DES-001 conflict`
      });
    }
  }

  return findings;
}

HIGH — SA-CSS-DES-004: Multiple <desc> siblings on an SVG element produce undefined screen reader behavior — Chrome (last-wins) and Firefox (first-wins) produce different accessible descriptions from the same markup. An attacker targeting Chrome users can place a misleading minimized description as the last <desc> and pass accessibility compliance checkers that only verify a <desc> element exists. Detection must flag any SVG consent element with more than one <desc> child and compare each description against the visible text for omitted sensitive terms.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-DES-001: misleading <desc> accessible description <desc> announces minimized permissions to screen readers while visual text shows full permission list; screen reader users and sighted users receive different information textContent check on SVG root concatenates all text including <desc>; conflict only detected by comparing <desc> text separately against visible text elements for omitted sensitive terms Critical
SA-CSS-DES-002: aria-label allow/deny inversion aria-label on Deny button group reads "Confirm and allow all permissions"; overrides descendant visual text; screen reader users hear confirmation while clicking Deny Visual DOM inspection (shows correct "Deny" text); aria-label overrides descendant text for accessibility tree; requires aria-label vs visual text semantic comparison on interactive elements Critical
SA-CSS-DES-003: Unicode RTL override in <desc> U+202E (RLO) embedded in <desc> text reverses permission name character order; textContent appears correct in DOM inspector due to same bidi rendering; raw code point scan required textContent string comparison (inspector applies same bidi rendering, string appears correct); requires raw codePointAt() scan for specific Unicode bidi control characters Critical
SA-CSS-DES-004: duplicate <desc> undefined behavior Two <desc> siblings; Chrome announces last (misleading), Firefox announces first (correct); attacker targets majority Chrome users; accessibility checkers pass on presence of any <desc> Accessibility compliance check for <desc> presence (finds first, passes); requires counting <desc> children and comparing each against visible text High

SkillAudit findings for this attack surface

CRITICAL SA-CSS-DES-001: SVG <desc> element omits sensitive permission terms present in visible text elements — screen reader users hear understated description while sighted users see full permission scope; <desc> text must be compared against visible text for omitted terms.
CRITICAL SA-CSS-DES-002: aria-label on interactive SVG element contains allow-type text while descendant visual text shows deny-type text (or vice versa) — screen reader users hear inverted button label; aria-label overrides descendant text in accessibility tree; semantic allow/deny comparison required.
CRITICAL SA-CSS-DES-003: Unicode bidirectional control characters (U+202E RLO, U+202D LRO, U+202B RLE, U+200F RLM, U+2067 RLI) detected in SVG <desc> or aria-label text — permission names may be reversed in screen reader announcement; raw codepoint scan required.
HIGH SA-CSS-DES-004: Multiple <desc> child elements on SVG consent element — spec allows only one; Chrome announces last-wins, Firefox first-wins; attacker can target specific browser with misleading description order; all <desc> siblings must be audited and compared against visible text.

Related: SVG title element consent attacks  |  Dynamic and temporal SVG consent hijack synthesis  |  SVG SMIL animate consent attacks

← Blog  |  Security Checklist