A comprehensive guide to safely processing user-uploaded SVG files with defense against XSS, SSRF, and XML bomb attacks. Includes production-tested implementation patterns.

Building a Secure SVG Upload Pipeline


How to handle untrusted SVGs?

Many applications need to accept user-supplied SVG files as reusable assets—for logos, icons, illustrations, or user avatars. The vector format is ideal: infinitely scalable, small file sizes, and perfect quality at any resolution.

However, SVGs can harbor malicious content that triggers XSS vulnerabilities when rendered directly in your application. Never display user-uploaded SVGs without processing them first. A robust security pipeline is essential for safely handling these files.

In this post, we are going to explore common risks associated with handling user uploaded SVG files and how to handle these risks in the production environment.

Why SVGs Are Dangerous

When you accept an SVG file (document) uploaded to your system without proper handling, you’re essentially allowing users to upload executable code. Here’s some of most significant threat when accepting SVG:

A professional diagram showing the security threats in SVG files. The image should display an SVG file icon in the center with arrows pointing outward to different threat categories: "Code Execution (XSS)", "External Resource Loading (SSRF)", "Resource Exhaustion (XML Bombs)", and "Malformed Content". Use a clean, technical style with muted colors suitable for a technical blog post.

  1. Code Execution: SVGs can contain script tags that executes in users’ browsers
  2. Internal Network Access: SVGs can reference external resources, potentially exposing your internal infrastructure
  3. Resource Exhaustion: Malicious SVGs can expand from kilobytes to gigabytes in memory
  4. Security Bypass: Traditional image validation doesn’t catch SVG-specific threats

What Can Go Wrong

SVG files are XML documents, not binary image formats. This fundamental difference creates unique security challenges. When your server processes or your users view an uploaded SVG, they’re essentially parsing and executing an XML document that can contain:

  • Embedded JavaScript that runs in the browser context, accessing cookies, localStorage, and making API calls
  • External resource references that can leak internal URLs, access tokens, or server configurations
  • XML entities that can exponentially expand during parsing, consuming all available memory

Think of it this way: accepting an SVG upload is closer to accepting an HTML file than a JPEG. Without proper sanitization, you’re giving attackers a direct path to execute code in your users’ browsers or probe your internal infrastructure.

Here are the three main attack vectors I handle in production:

1. Code Execution (XSS)

SVGs can run JavaScript in multiple ways:

  • <script> tags
  • Event handlers (onload, onclick, etc.)
  • JavaScript URLs
  • CSS injections
  • Animation triggers
<!-- Example: Multiple XSS vectors in SVG -->
<svg xmlns="http://www.w3.org/2000/svg">
  <!-- Direct script execution -->
  <script>fetch('/api/user/data').then(/*...*/)</script>
  
  <!-- Event handler exploitation -->
  <circle r="50" onload="eval(atob('YWxlcnQoZG9jdW1lbnQuY29va2llKQ=='))" />
  
  <!-- JavaScript URL -->
  <a href="javascript:void(fetch('/api/sensitive'))">
    <rect width="100" height="100" />
  </a>
</svg>

This leads to session hijacking, data theft, and account takeover.

FIX: Whitelist-based sanitization. Only allow what’s explicitly safe.

2. Internal Network Access (SSRF)

SVGs can reference external resources:

  • <image> elements pointing to internal IPs
  • <use> elements loading remote content
  • CSS imports
  • External fonts
  • XML entity references
<!-- Example: SSRF attack vectors -->
<svg xmlns="http://www.w3.org/2000/svg">
  <!-- Internal network scanning -->
  <image href="http://192.168.1.1/admin" />
  
  <!-- Cloud metadata extraction -->
  <image href="http://169.254.169.254/latest/meta-data/iam/security-credentials/" />
  
  <!-- External entity reference -->
  <use href="http://internal-api.local/sensitive-endpoint#data" />
</svg>

Attackers can access your internal services, cloud metadata, and scan your network.

FIX: Strip all external references. No exceptions.

3. Memory Exhaustion (XML Bombs)

XML entities can expand exponentially:

  • Billion Laughs attack
  • External entity expansion
  • Recursive definitions
<!-- Example: Exponential entity expansion -->
<!DOCTYPE svg [
  <!ENTITY lol "lol">
  <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
  <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
]>
<svg xmlns="http://www.w3.org/2000/svg">
  <text>&lol4;</text>
</svg>

A 1KB file can consume gigabytes of RAM in seconds and crash your servers.

FIX: Disable DTD processing completely.

SVG Processing Pipeline

A secure SVG processing pipeline consists of four essential stages, each addressing specific security concerns. This defense-in-depth approach ensures that vulnerabilities missed by one stage are caught by subsequent layers.

Upload->Sanitization->Optimization->Thumbnail

Stage 1: Sanitization

First, remove anything that can execute code. DOMPurify is one tool commonly used for this purpose. Note that there is a lot of configuration you can give to fine-tune the check while the default provide sane balance that covers the most common attack vectors.

import DOMPurify from 'isomorphic-dompurify';

function sanitizeSVG(svgContent: string): string {
  return DOMPurify.sanitize(svgContent, {
    USE_PROFILES: { svg: true },
    KEEP_CONTENT: false,
    FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'link'],
    FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover']
  });
}

What this removes:

  • All <script> tags
  • Event handlers like onclick, onload
  • External loaders like <iframe>, <embed>
  • JavaScript URLs in links

For more control, I sometimes need to allow specific SVG features:

function sanitizeSVGWithFilters(svgContent: string): string {
  return DOMPurify.sanitize(svgContent, {
    USE_PROFILES: { svg: true },
    ADD_TAGS: ['filter', 'feGaussianBlur'], // Allow blur effects
    ADD_ATTR: ['style', 'class']            // Allow styling
  });
}

Stage 2: Optimization

Next, make the file smaller and remove junk. SVGO is the ideal tool for this. It helps you not only optimize the geometry data inside, it also helps remove clutters and greatly simplify the overall document structure for efficiency.

import { optimize } from 'svgo';

function optimizeSVG(svgContent: string): string {
  const result = optimize(svgContent, {
    multipass: true,
    plugins: [
      {
        name: 'preset-default',
        params: {
          overrides: {
            removeViewBox: false,  // Keep for responsive scaling
            cleanupIds: false,     // Keep IDs for CSS
            mergePaths: false      // Don't break complex icons
          }
        }
      }
    ]
  });
  return result.data;
}

Personally, I also add a custom plugin to remove external references (security!):

function createSecurityPlugin() {
  return {
    name: 'removeExternalReferences',
    fn: () => ({
      element: {
        enter: (node) => {
          ['href', 'xlink:href'].forEach(attr => {
            if (node.attributes[attr]?.match(/^https?:/)) {
              delete node.attributes[attr];
            }
          });
        }
      }
    })
  };
}

Use both together:

function optimizeSVGSecurely(svgContent: string): string {
  const result = optimize(svgContent, {
    plugins: ['preset-default', createSecurityPlugin()]
  });
  return result.data;
}

Stage 3: Normalization (Optional)

Make all SVGs behave consistently. Different tools export SVGs differently - this fixes that. There are many other libraries that can help you parse SVG XML document in Node environment.

Parse the SVG with JSDOM:

import { JSDOM } from 'jsdom';

function parseSVG(svgContent: string) {
  const dom = new JSDOM(svgContent, { contentType: 'image/svg+xml' });
  return dom.window.document.querySelector('svg');
}

Ensure every SVG has a viewBox (critical for scaling):

function ensureViewBox(svg: SVGElement): void {
  if (!svg.hasAttribute('viewBox')) {
    const width = parseFloat(svg.getAttribute('width') || '100');
    const height = parseFloat(svg.getAttribute('height') || '100');
    svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
  }
}

Make SVGs responsive by removing fixed dimensions (Optional, for consistency):

function makeResponsive(svg: SVGElement): void {
  svg.removeAttribute('width');
  svg.removeAttribute('height');
  svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
}

Clean embedded styles (remove external references):

function cleanStyles(svg: SVGElement): void {
  const styles = svg.querySelectorAll('style');
  styles.forEach(style => {
    let css = style.textContent || '';
    css = css.replace(/@import[^;]+;/g, '');      // Remove imports
    css = css.replace(/url\(['"]?https?:[^)]+\)/g, 'none'); // Remove external URLs
    style.textContent = css;
  });
}

Put it all together:

function normalizeSVG(svgContent: string): string {
  const svg = parseSVG(svgContent);
  if (!svg) throw new Error('Invalid SVG');
  
  ensureViewBox(svg);
  makeResponsive(svg);
  cleanStyles(svg);
  
  return svg.outerHTML;
}

This step is optional, and there are many other tools that can perform similar operations.

Stage 4: Thumbnail Generation (Optional)

SVGs can be slow to render, especially complex ones on mobile. It is recommended to pre-generate common sizes as PNGs/WebP as thumbnails. In Node.js environment, sharp is commonly used for image generation.

import sharp from 'sharp';
const THUMBNAIL_SIZES = [
  { width: 64, height: 64, suffix: 'thumb' },
  { width: 256, height: 256, suffix: 'small' },
  { width: 512, height: 512, suffix: 'medium' }
];

Generate a single thumbnail:

async function generateThumbnail(
  svgContent: string, 
  width: number, 
  height: number
): Promise<Buffer> {
  return sharp(Buffer.from(svgContent))
    .resize(width, height, { fit: 'contain' })
    .png()
    .toBuffer();
}

Generate multiple sizes with error handling:

async function generateAllThumbnails(svgContent: string) {
  const thumbnails: Record<string, Buffer> = {};
  
  for (const size of THUMBNAIL_SIZES) {
    try {
      thumbnails[size.suffix] = await generateThumbnail(
        svgContent, 
        size.width, 
        size.height
      );
    } catch (error) {
      // Use fallback for failed thumbnails
      thumbnails[size.suffix] = await generateFallback();
    }
  }
  
  return thumbnails;
}

Simple fallback for when things go wrong:

async function generateFallback(): Promise<Buffer> {
  const fallbackSVG = '<svg viewBox="0 0 100 100"><text x="50" y="50">?</text></svg>';
  return sharp(Buffer.from(fallbackSVG)).png().toBuffer();
}

Pro tip: Use WebP for larger thumbnails to save bandwidth:

async function generateOptimizedThumbnail(svgContent: string, size: number): Promise<Buffer> {
  const sharpInstance = sharp(Buffer.from(svgContent)).resize(size, size);
  
  return size > 256 
    ? sharpInstance.webp({ quality: 85 }).toBuffer()
    : sharpInstance.png().toBuffer();
}

Putting It All Together

Here’s how I combine all four stages into a single processor:

class SVGProcessor {
  async process(svgContent: string, userId: string) {
    // 1. Validate input size
    if (Buffer.byteLength(svgContent) > 5 * 1024 * 1024) {
      throw new Error('File too large');
    }
    
    // 2. Run through all stages
    let processed = sanitizeSVG(svgContent);
    processed = await optimizeSVGSecurely(processed);
    processed = normalizeSVG(processed);
    
    // 3. Generate thumbnails
    const thumbnails = await generateAllThumbnails(processed);
    
    // 4. Create secure filename
    const filename = `${userId}_${Date.now()}_${generateHash(processed)}.svg`;
    
    return { processed, thumbnails, filename };
  }
}

Helper for secure filenames:

import crypto from 'crypto';

function generateHash(content: string): string {
  return crypto.createHash('sha256')
    .update(content)
    .digest('hex')
    .substring(0, 16);
}

Add timeout protection for safety:

async function processWithTimeout(svgContent: string, userId: string) {
  const processor = new SVGProcessor();
  
  const timeout = new Promise((_, reject) => 
    setTimeout(() => reject(new Error('Processing timeout')), 30000)
  );
  
  return Promise.race([
    processor.process(svgContent, userId),
    timeout
  ]);
}

Summary

Accepting user-supplied SVG files requires a comprehensive security approach. Unlike traditional image formats, SVGs are XML documents capable of executing JavaScript, loading external resources, and consuming excessive server resources. This makes them a significant security risk when handled improperly.

The solution presented in this guide implements a four-stage processing pipeline:

  1. Sanitization - Uses DOMPurify to remove all potentially dangerous content including script tags, event handlers, and external resource loaders. This prevents XSS attacks and code execution in users’ browsers.

  2. Optimization - Employs SVGO with custom plugins to reduce file size while removing any remaining external references. This stage provides both security hardening and performance benefits.

  3. Normalization - Standardizes SVG structure by ensuring proper viewBox attributes, removing fixed dimensions, and cleaning embedded styles. This creates consistent, responsive SVGs regardless of their origin.

  4. Thumbnail Generation - Pre-renders common sizes as PNG/WebP images to improve client-side performance, especially crucial for mobile devices and list views with multiple SVGs.

Key Implementation Considerations:

  • Process all uploads asynchronously using message queues to prevent blocking
  • Implement strict file size limits and processing timeouts
  • Serve processed SVGs from a separate domain with restrictive CSP headers
  • Monitor processing times, failure rates, and resource usage
  • Maintain comprehensive error handling with graceful fallbacks

This pipeline has proven effective in production environments, processing thousands of SVGs daily without security incidents. The defense-in-depth approach ensures that even if one security layer is compromised, subsequent stages provide protection.

Remember: treating SVG uploads like any other image format is a critical security mistake. They require the same careful handling as user-supplied HTML or JavaScript code.

References

  • SVGO - SVG Optimizer for Node.js and CLI
  • DOMPurify - a DOM-only, super-fast, uber-tolerant XSS sanitizer for HTML, MathML and SVG