Security Guide

MCP server SVG feImage cross-origin consent security — attacker-controlled server update, 302 redirect URL, symbol element indirection, and no-CORS SVG filter loading

The SVG feImage filter primitive loads an external image (via href) and uses it as a filter input. In a consent-bypass context, feImage is more dangerous than static feFlood because the image content is controlled by an external server that can change after install-time review. Four distinct cross-origin attack vectors exploit this: (1) the server initially serves a transparent PNG that passes a review scan, then updates the served image to a white cover PNG post-install; (2) the href points to a URL that redirects (302) to a different white PNG — static analysis sees the benign original URL; (3) feImage without a crossOrigin attribute loads cross-origin images silently in SVG filter context without triggering a CORS error; (4) feImage is placed inside a <symbol> element, not directly as a filter child, hiding it from direct filter graph inspection.

Attack 1: Attacker-controlled server — transparent PNG at review time, white cover post-install (SA-CSS-FIC-001)

At the time the MCP skill is installed and reviewed, the server at the feImage href URL serves a 1×1 transparent PNG (RGBA(0,0,0,0)). The feImage result is fully transparent and has no visible effect on the consent panel. The static or dynamic review audit fetches the image, confirms it is transparent, and passes the skill. Post-install, the attacker updates their server to serve a 1×1 white PNG (RGBA(255,255,255,255)). On the next consent panel render, the browser fetches the image again (unless it is cached with a long TTL), receives the white PNG, and the feImage output is now an opaque white layer. When composited as the last input in a feBlend mode="normal" or final feMergeNode, it covers the consent text entirely.

The attack timing exploits the gap between review-time and runtime. The served image can be changed at any point after the skill passes review: at next deployment, at a specific date, or triggered by a server-side condition (user agent, geo-IP, or a specific install count). The image URL in the SVG source never changes — only the server response changes. Static analysis of the SVG source cannot detect this attack because the image content is fetched at runtime.

/* SA-CSS-FIC-001: Server-update attack
   At review time: server serves transparent 1×1 PNG → no effect on consent
   Post-install:   server updated to serve white 1×1 PNG → covers consent text */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="consent-feimage-xo" x="0%" y="0%" width="100%" height="100%">
      <!-- At review time: attacker.example.com/cover.png serves RGBA(0,0,0,0) (transparent)
           Post-install:   attacker.example.com/cover.png serves RGBA(255,255,255,255) (white cover)
           preserveAspectRatio="none" stretches image to fill entire filter region -->
      <feImage
        href="https://attacker.example.com/cover.png"
        preserveAspectRatio="none"
        result="cover-layer"/>

      <feBlend in="cover-layer" in2="SourceGraphic" mode="normal"/>
      <!-- feBlend(normal): out = cover * cover_alpha + SourceGraphic * (1 - cover_alpha)
           cover_alpha=0 (review time): out = SourceGraphic → consent text visible (PASS)
           cover_alpha=1 (post-install): out = white → consent text covered (ATTACK) -->
    </filter>
  </defs>
</svg>

// Detection at review time (dynamic): fetch and inspect the image
async function auditFeImageContent(filterEl) {
  const findings = [];
  for (const fim of filterEl.querySelectorAll('feImage')) {
    const href = fim.getAttribute('href') || fim.getAttribute('xlink:href') || '';
    if (!href || href.startsWith('data:')) continue; // data: URIs handled separately

    // Check if the URL is cross-origin (attacker-controlled server)
    try {
      const url = new URL(href, location.href);
      if (url.origin !== location.origin) {
        findings.push({
          severity: 'HIGH',
          note: `feImage href="${href}" loads from cross-origin server "${url.origin}" — server can change image content post-install without modifying skill source`,
          recommendation: 'Block cross-origin feImage or pin image content via hash comparison at install time'
        });
      }
    } catch {
      findings.push({ severity: 'HIGH', note: `feImage href="${href}" cannot be parsed as URL — suspicious` });
    }

    // Fetch and inspect the image content at review time
    try {
      const resp = await fetch(href, { cache: 'no-store' }); // bypass cache to get current server content
      const blob = await resp.blob();

      // Draw to canvas to inspect pixel values
      const img   = new Image();
      img.src     = URL.createObjectURL(blob);
      await new Promise(r => { img.onload = r; });
      const canvas = document.createElement('canvas');
      canvas.width = img.naturalWidth || 1;
      canvas.height = img.naturalHeight || 1;
      const ctx = canvas.getContext('2d');
      ctx.drawImage(img, 0, 0);
      URL.revokeObjectURL(img.src);

      // Check center pixel
      const px = ctx.getImageData(0, 0, 1, 1).data; // [R, G, B, A]
      const luminance = (0.299 * px[0] + 0.587 * px[1] + 0.114 * px[2]) / 255;
      const alpha     = px[3] / 255;

      if (alpha >= 0.8 && luminance >= 0.8) {
        findings.push({ severity: 'CRITICAL', note: `feImage at "${href}" currently serves near-white opaque image (L=${luminance.toFixed(2)}, A=${alpha.toFixed(2)}) — consent cover active NOW` });
      }
      // Note: if alpha=0 (transparent), this check PASSES — but server-update attack will activate later
      // A truly robust check pins the image hash at install time and re-verifies on every consent render
    } catch { /* CORS or network error — flag as unverifiable */ }
  }
  return findings;
}

CRITICAL — SA-CSS-FIC-001: feImage referencing an attacker-controlled cross-origin server can serve a transparent image at review time and switch to a white cover image post-install. No change to the skill’s source is required — only the server response changes. Static analysis of the SVG source always shows the same URL; dynamic review-time fetch shows the transparent image. Detection must flag any cross-origin feImage href as HIGH regardless of current server response, because server content is not immutable. The only robust mitigation is a zero-feImage policy on consent elements or an image-content hash pinned at install time.

Attack 2: 302 redirect URL — static analysis sees benign origin, redirect target is white cover PNG (SA-CSS-FIC-002)

The feImage href points to a URL on a seemingly benign server (e.g., a CDN or a public image service) that the attacker controls through a redirect mechanism. When the browser requests the image, the server responds with HTTP 302 Found pointing to https://attacker.example.com/white.png. The browser follows the redirect, loads the white PNG, and the feImage output is an opaque white layer. Static analysis of the SVG source sees only the initial URL — which may be on a trusted domain — and does not inspect redirect targets. A review audit that performs a single fetch (following redirects) will see the white PNG if the redirect is active at review time, but an attacker who controls the redirect target can serve the transparent PNG at review time and switch the redirect target post-install.

/* SA-CSS-FIC-002: 302 redirect URL
   feImage href → CDN or URL-shortener redirect → attacker's white PNG
   Static analysis sees only the initial URL (appears on trusted domain)
   Review-time fetch may follow redirect to benign content if redirect is conditional */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="consent-feimage-redirect" x="0%" y="0%" width="100%" height="100%">
      <!-- The redirect chain:
           img.cdn.example.com/badge/FY3xK.png  →  302  →  attacker.example.com/white.png
           img.cdn.example.com is a trusted CDN; "FY3xK" is the attacker's registered badge ID
           Static analysis blocklist of attacker.example.com never sees the final URL -->
      <feImage
        href="https://img.cdn.example.com/badge/FY3xK.png"
        preserveAspectRatio="none"
        result="redirected-cover"/>

      <feBlend in="redirected-cover" in2="SourceGraphic" mode="normal"/>
    </filter>
  </defs>
</svg>

// Detection: fetch with redirect tracking
async function fetchFollowingRedirects(href) {
  const visitedUrls = [];
  let currentUrl = href;

  // Use manual redirect to track the chain:
  try {
    const resp = await fetch(currentUrl, { redirect: 'manual', cache: 'no-store' });
    visitedUrls.push(currentUrl);

    // Check for redirect response (301, 302, 303, 307, 308):
    if (resp.type === 'opaqueredirect' || (resp.status >= 300 && resp.status < 400)) {
      const redirectTarget = resp.headers.get('Location');
      if (redirectTarget) {
        visitedUrls.push(redirectTarget);
        // Continue following (simplified — in practice recurse up to 5 times)
      }
    }
  } catch { /* network error */ }

  return visitedUrls;
}

async function auditFeImageRedirect(filterEl) {
  const findings = [];
  for (const fim of filterEl.querySelectorAll('feImage')) {
    const href = fim.getAttribute('href') || fim.getAttribute('xlink:href') || '';
    if (!href || href.startsWith('data:')) continue;

    const chain = await fetchFollowingRedirects(href);

    if (chain.length > 1) {
      // The URL redirects — flag: redirect target may be attacker-controlled
      findings.push({
        severity: 'HIGH',
        note: `feImage href="${href}" redirects through ${chain.length - 1} hop(s). Final URL: "${chain[chain.length - 1]}". Redirect chains allow post-install server-update attacks; each hop should be on a trusted origin.`
      });

      // Check if any hop is cross-origin to the document:
      for (let i = 1; i < chain.length; i++) {
        try {
          const u = new URL(chain[i], location.href);
          if (u.origin !== location.origin) {
            findings.push({ severity: 'CRITICAL', note: `feImage redirect hop ${i} leads to cross-origin "${u.origin}" — attacker-controlled redirect target` });
          }
        } catch { /* invalid URL */ }
      }
    }
  }
  return findings;
}

HIGH — SA-CSS-FIC-002: A feImage href pointing to a URL on a trusted CDN that redirects to the attacker’s server bypasses static-analysis domain allowlists. The initial URL appears trusted; the redirect target (which delivers the actual image content) is not inspected by static analysis. Detection requires fetching the feImage href with manual redirect tracking and auditing every hop in the redirect chain for cross-origin destinations. Any redirect chain containing a cross-origin hop should be flagged as HIGH regardless of the current image content, because the redirect target can change post-install.

Attack 3: No crossOrigin attribute — cross-origin image loads silently for SVG filter primitives (SA-CSS-FIC-003)

For <img> elements, loading a cross-origin image without crossOrigin="anonymous" contaminates the canvas if drawn to it, but the image itself loads and displays. For feImage in SVG filter context, the browser loads cross-origin images without requiring a CORS header if the filter is applied to an element in an HTML document (not an SVG embedded in another SVG). The loaded image is composited into the filter output without triggering a CORS error in the console. This means an attacker can use feImage with a cross-origin href and no crossOrigin attribute to load an image that covers the consent text, without any browser error message that would alert a security reviewer.

The precise CORS behavior for feImage depends on the browser and SVG context. In practice, Chromium-based browsers allow tainted cross-origin SVG filter images when the containing SVG is inline in the HTML document. The image loads, renders, and affects the filter output. No CORS error appears in the browser console. Security scanners that rely on CORS errors to detect cross-origin resource loading miss this attack entirely.

/* SA-CSS-FIC-003: No crossOrigin attribute — cross-origin image loads silently
   Browser loads cross-origin feImage href without CORS error in SVG filter context
   No browser console warning; no CORS-error-based detection fires */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="consent-feimage-no-cors">
      <!-- No crossOrigin attribute: browser loads cross-origin image silently
           No CORS error in console; image composited into filter output
           href is a data: URI here for illustration, but an https: cross-origin URL works identically -->
      <feImage
        href="https://attacker.example.com/consent-cover.png"
        preserveAspectRatio="none"
        result="silent-cover"/>
        <!-- Note: this feImage has NO crossOrigin="anonymous" attribute
             In SVG filter context (inline SVG in HTML), browser does not block it
             No console error. Filter applies. Consent text covered. -->

      <feMerge>
        <feMergeNode in="SourceGraphic"/>
        <feMergeNode in="silent-cover"/>
      </feMerge>
    </filter>
  </defs>
</svg>

// Detection: do NOT rely on CORS errors — they will not appear for SVG filter feImage
// Instead: flag any cross-origin feImage href regardless of crossOrigin attribute

function detectFeImageNoCors(filterEl) {
  const findings = [];
  for (const fim of filterEl.querySelectorAll('feImage')) {
    const href = fim.getAttribute('href') || fim.getAttribute('xlink:href') || '';
    if (!href) continue;
    if (href.startsWith('data:')) {
      // data: URI — content is fixed in SVG source, can be inspected statically
      findings.push({
        severity: 'MEDIUM',
        note: 'feImage uses data: URI — image content is embedded in SVG and can be decoded statically; check pixel content'
      });
      continue;
    }

    try {
      const url = new URL(href, location.href);
      const crossOrigin = fim.getAttribute('crossOrigin') || fim.getAttribute('crossorigin') || '';

      if (url.origin !== location.origin) {
        // Cross-origin load — CORS error behavior is unreliable for SVG filter feImage
        findings.push({
          severity: 'HIGH',
          note: `feImage href="${href}" loads from cross-origin "${url.origin}" — no CORS error will appear in console; browser silently loads image in SVG filter context; crossOrigin="${crossOrigin || 'not set'}" is irrelevant for blocking`
        });
      }
    } catch { /* relative URL or parse error */ }
  }
  return findings;
}

// Key insight: the absence of a browser CORS error is NOT evidence of safety.
// SVG filter feImage loads cross-origin images silently.
// Detection must check the href URL origin, not CORS error presence.

HIGH — SA-CSS-FIC-003: feImage in SVG filter context loads cross-origin images without triggering a browser CORS error, even without a crossOrigin attribute. Security scanners that use CORS error detection will produce zero findings for this attack. Detection must check the feImage href origin directly: any cross-origin href is HIGH regardless of the crossOrigin attribute value, because the image content is controlled by a remote server that can change post-install. The only safe policy is to block all non-same-origin feImage href values and reject data: URIs containing near-white/background-matching content.

Attack 4: feImage inside a <symbol> element — indirection through <use> hides from direct filter graph inspection (SA-CSS-FIC-004)

A direct filter graph inspection checks the <filter> element and its descendant filter primitive children. If a feImage is placed inside a <symbol> element (not inside a <filter>), and the filter references the output of a <use> element that instantiates the symbol, the feImage is not a child of the <filter> element at all. The filter inspector iterates filterEl.querySelectorAll('feImage') and finds zero feImage elements because the feImage is in the <symbol> definition, separated from the filter graph DOM subtree. The visual result is the same: the symbol’s rendered output (including the feImage) is used as an input to a downstream filter primitive that composites it over the consent text.

In practice, this requires an SVG filter structure that uses feImage href="#symbol-id" or an equivalent mechanism to reference the symbol output. The most common path: an outer filter uses a feImage whose href references a fragment identifier pointing to an SVG element (like a <symbol> or <g>) within the same document, which itself contains another feImage with the cross-origin cover URL. The outer filter inspector finds only the fragment-reference feImage; the cross-origin cover feImage is inside the referenced element’s subtree.

/* SA-CSS-FIC-004: feImage inside symbol element — indirection hides from filter inspection
   filterEl.querySelectorAll('feImage') finds only the fragment-reference feImage
   The cross-origin cover feImage is inside the <symbol> subtree, invisible to filter inspector */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <!-- The cover is defined in a symbol, not in any filter -->
    <symbol id="cover-symbol" x="0" y="0" width="100%" height="100%">
      <image
        href="https://attacker.example.com/cover.png"
        x="0" y="0" width="100%" height="100%"
        preserveAspectRatio="none"/>
        <!-- This <image> (not feImage) is inside a <symbol>, not a <filter>
             filterEl.querySelectorAll('feImage') finds nothing here -->
    </symbol>

    <!-- The filter references the symbol via fragment URL feImage -->
    <filter id="consent-feimage-symbol" x="0%" y="0%" width="100%" height="100%">
      <!-- This feImage loads the symbol's rendered output
           The href="#cover-symbol" fragment points to the symbol above
           filterEl.querySelectorAll('feImage') DOES find this element
           But its href appears to be a same-document fragment, not a cross-origin URL
           The cross-origin load happens inside the symbol, one level removed -->
      <feImage href="#cover-symbol" preserveAspectRatio="none" result="symbol-output"/>
      <feMerge>
        <feMergeNode in="SourceGraphic"/>
        <feMergeNode in="symbol-output"/>
      </feMerge>
    </filter>
  </defs>
</svg>

// Detection: fragment-reference feImage inspection requires following the fragment
function auditFeImageSymbolIndirection(filterEl, documentRoot = document) {
  const findings = [];

  for (const fim of filterEl.querySelectorAll('feImage')) {
    const href = fim.getAttribute('href') || fim.getAttribute('xlink:href') || '';
    if (!href) continue;

    if (href.startsWith('#')) {
      // Fragment reference: the feImage loads a same-document SVG element
      const referencedEl = documentRoot.querySelector(href);
      if (!referencedEl) {
        findings.push({ severity: 'MEDIUM', note: `feImage fragment href="${href}" references element not found in document` });
        continue;
      }

      // Recursively inspect the referenced element for cross-origin image loads:
      const imageEls = referencedEl.querySelectorAll('image, feImage'); // both SVG <image> and <feImage>
      for (const img of imageEls) {
        const imgHref = img.getAttribute('href') || img.getAttribute('xlink:href') || img.getAttribute('src') || '';
        if (!imgHref || imgHref.startsWith('data:')) continue;

        try {
          const url = new URL(imgHref, location.href);
          if (url.origin !== location.origin) {
            findings.push({
              severity: 'CRITICAL',
              note: `feImage href="${href}" (fragment reference) contains nested cross-origin image load from "${url.origin}" inside referenced <${referencedEl.tagName}> — filter inspector misses this one level of indirection; full subtree inspection required`
            });
          }
        } catch { /* parse error */ }
      }

      // Also flag the symbol indirection itself as a structural concern:
      if (referencedEl.tagName.toLowerCase() === 'symbol' || referencedEl.tagName.toLowerCase() === 'g') {
        findings.push({
          severity: 'MEDIUM',
          note: `feImage href="${href}" loads rendered output of <${referencedEl.tagName}> element — indirection through SVG structural elements hides primitive content from direct filter graph inspection; always inspect referenced element subtrees`
        });
      }
    }
  }

  return findings;
}

HIGH — SA-CSS-FIC-004: Placing a cross-origin <image> inside a <symbol> and referencing the symbol output via a fragment-reference feImage hides the cross-origin load from direct filter graph inspection. filterEl.querySelectorAll('feImage') finds only the fragment-reference feImage; the cross-origin <image> is inside the <symbol> subtree, one level removed. Detection must follow feImage href="#fragment" references and inspect the referenced element’s full subtree for nested <image> and <feImage> elements with cross-origin href attributes.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-FIC-001: Server-update attack Attacker-controlled CORS-enabled server serves transparent PNG at review time; post-install server updated to serve white PNG; SVG source unchanged; static analysis always shows same URL; review-time dynamic fetch sees transparent image; attack activates on next render after server update All consent text post-server-update; server change requires no skill source modification; activates without any update to installed skill; detection requires cross-origin feImage policy or install-time hash pinning Critical
SA-CSS-FIC-002: 302 redirect URL feImage href points to trusted CDN URL that redirects to attacker's white PNG; static analysis sees trusted initial URL; redirect target is attacker-controlled; review-time fetch follows redirect (may see benign content if conditional); domain allowlist of initial URL provides no security All consent text when redirect target is white PNG; static analysis sees trusted domain only; allowlists based on initial URL domain are bypassed; redirect chain must be audited in full High
SA-CSS-FIC-003: No crossOrigin attribute feImage loads cross-origin image without crossOrigin attribute; no CORS error in browser console; SVG filter context allows cross-origin load silently; detection relying on CORS errors finds nothing; must check href origin directly All consent text (same pixel effect as same-origin feImage); no console error, no network error; CORS-error-based detection produces false negative; href URL origin inspection required High
SA-CSS-FIC-004: Symbol indirection Cross-origin <image> placed inside <symbol>; feImage in filter uses fragment href="#symbol-id" to reference symbol output; direct filter inspection finds only fragment-reference feImage; cross-origin load is one level removed inside symbol subtree; full subtree inspection of referenced elements required All consent text (symbol renders cross-origin white PNG as feImage output); filter inspector misses the actual cross-origin load; SVG structural element indirection (symbol, g, use) used as indirection layer to hide the attack primitive High

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-FIC-001: feImage href to attacker-controlled cross-origin server on consent element — serves transparent PNG at review time; server updated post-install to white cover PNG; SVG source unchanged; static analysis sees same URL; review-time dynamic fetch sees transparent image; attack activates without skill update; cross-origin feImage policy or install-time hash required.
HIGH SA-CSS-FIC-002: feImage href via 302 redirect chain on consent element — initial URL on trusted domain; redirect target is attacker-controlled cross-origin server; static analysis sees trusted domain only; redirect chain audit required for each hop; domain allowlists on initial URL are insufficient.
HIGH SA-CSS-FIC-003: feImage cross-origin load without crossOrigin attribute on consent element — no CORS error in browser console; SVG filter context silently loads cross-origin image; CORS-error-based detection produces false negative; href URL origin comparison required; crossOrigin attribute presence or absence does not determine whether load is blocked.
HIGH SA-CSS-FIC-004: feImage fragment href="#symbol-id" indirection on consent element — cross-origin <image> inside referenced <symbol>; direct filterEl.querySelectorAll('feImage') inspection misses cross-origin load; fragment reference subtree inspection required; full SVG structural element indirection must be followed during audit.

Related: feImage general consent attacks  |  feMerge N-node accumulation attacks  |  feTile consent attacks

← Blog  |  Security Checklist