Sanitizing SVG Uploads Against XSS

Treat every uploaded SVG as an HTML document: parse it server-side, run it through DOMPurify’s SVG profile with an explicit allow-list (no script, no foreignObject, no event-handler attributes, no javascript: or external hrefs), reject files that do not survive with the same root element, store the sanitised output rather than the original, and serve it from a separate domain with Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline' and Content-Disposition set so that even a missed payload cannot run in your origin — or, if you only need images, rasterise SVGs to PNG and never serve the XML at all.

SVG is the one common image format that is also a scripting environment. A file named logo.svg can contain <script>, onload handlers, <foreignObject> with arbitrary HTML, links to javascript: URLs and references to external resources. Opened directly in a browser tab from your domain, it runs with your cookies. This is not theoretical: SVG upload XSS is a staple of bug bounty reports. This page belongs to server-side file validation in backend validation and cloud storage architecture. For identifying SVGs reliably despite their name, see handling files with missing or wrong extensions.

When to use this approach

  • Users upload logos, icons, diagrams or illustrations and you want to keep them as vectors.
  • SVGs are displayed on your site, embedded in documents, or downloadable by other users.
  • You cannot simply reject SVG — designers expect it — but you must not trust it.

Prerequisites

  1. Node 20+ with dompurify 3.x and jsdom 24+ (DOMPurify needs a DOM on the server).
  2. A separate hostname for user content (usercontent.example.net), not a subdomain of your app’s cookie domain.
  3. Optionally sharp 0.33+ with librsvg for rasterisation.
  4. A size limit on uploads before parsing — XML parsers can be made to do a lot of work.

How an SVG runs script

Ways a malicious SVG executes script and whether each context allows it An SVG can carry a script element, event handler attributes like onload, a foreignObject containing HTML, or an anchor with a javascript URL. Loaded in an img tag, none of these run. Opened directly in a tab, inlined into the page, or loaded in an object or iframe, all of them can run with the origin's privileges. Same file, different rendering contexts payload <img src> direct tab inline / iframe <script>…</script> inert runs runs onload="…" attributes inert runs runs <foreignObject> HTML inert runs runs <a href="javascript:…"> inert on click on click You control how your pages embed an SVG; you do not control a user opening its URL directly. That is why sanitising and serving from another origin are both needed.
<img> is safe, but the file's URL is always one click away from the unsafe contexts.

Implementation

import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
import sharp from "sharp";
import { readFile } from "node:fs/promises";

const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);

const MAX_BYTES = 2 * 1024 * 1024;           // SVGs bigger than this are almost never legitimate icons

// Remove any href / xlink:href that is not an in-document fragment.
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
  for (const attr of ["href", "xlink:href"]) {
    const v = node.getAttribute?.(attr);
    if (v !== null && v !== undefined && !v.startsWith("#")) node.removeAttribute(attr);
  }
  if (node.nodeName.toLowerCase() === "style") {
    // Drop CSS that loads external resources: url(), @import.
    node.textContent = (node.textContent ?? "").replace(/@import[^;]+;?/gi, "").replace(/url\([^)]*\)/gi, "none");
  }
});

export interface SvgResult { ok: boolean; svg?: string; reason?: string; removed?: number }

export function sanitizeSvg(input: Buffer): SvgResult {
  if (input.length > MAX_BYTES) return { ok: false, reason: "SVG larger than 2 MB" };
  const text = input.toString("utf8");
  if (/<!ENTITY/i.test(text)) return { ok: false, reason: "DTD entities are not allowed" };   // XXE / billion laughs

  const clean = DOMPurify.sanitize(text, {
    USE_PROFILES: { svg: true, svgFilters: true },
    FORBID_TAGS: ["foreignObject", "script", "iframe", "embed", "object", "animate", "set"],
    FORBID_ATTR: ["style"],                  // inline styles can carry url(); use <style> after the hook
    ALLOW_DATA_ATTR: false,
    RETURN_TRUSTED_TYPE: false,
  }) as string;

  const removed = DOMPurify.removed.length;
  const root = new window.DOMParser().parseFromString(clean, "image/svg+xml").documentElement;
  if (root.localName !== "svg" || root.namespaceURI !== "http://www.w3.org/2000/svg") return { ok: false, reason: "not a valid SVG after sanitising", removed };
  return { ok: true, svg: `<?xml version="1.0" encoding="UTF-8"?>\n${clean}`, removed };
}

/** Alternative: turn the SVG into pixels and never serve the XML. */
export async function rasterise(svg: string, width = 1024): Promise<Buffer> {
  return sharp(Buffer.from(svg), { density: 144, limitInputPixels: 50_000_000 })
    .resize({ width, withoutEnlargement: true })
    .png()
    .toBuffer();
}

// Usage in an upload processor: the fixture has an onload handler and a <script> fetching /api/me
const r = sanitizeSvg(await readFile("fixtures/xss/onload-and-script.svg"));
console.log(r.ok, r.removed);   // true 2 — the circle survives, the handler and the script do not

And the headers for serving user SVGs:

export const SVG_HEADERS = {
  "Content-Type": "image/svg+xml",
  "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox",
  "X-Content-Type-Options": "nosniff",
  "Content-Disposition": "inline; filename=\"image.svg\"",
  "Cross-Origin-Resource-Policy": "cross-origin",
};

Line-by-line on the decisions that matter

  • DOMPurify with the SVG profile. DOMPurify parses with a real DOM, which handles the tricks string filters miss — mixed case, namespaced event handlers, entities, malformed markup that browsers repair into something executable. The SVG profile allows drawing elements and drops everything else by default.
  • FORBID_TAGS beyond the defaults. foreignObject is how HTML (and therefore forms, iframes and script) gets into an SVG; animate and set can rewrite attributes such as href to javascript: at runtime. Forbidding them costs almost nothing for real-world icons.
  • The href hook. External references let an SVG fetch resources when rendered — tracking, SSRF when rasterised server-side, or javascript: links. Allowing only #fragment references keeps gradients, patterns and <use> working.
  • Rejecting <!ENTITY. XML entity expansion can turn a tiny file into gigabytes (the “billion laughs” attack) or read local files in misconfigured parsers. Legitimate SVGs from design tools do not need DTDs.
  • Storing the sanitised output. Sanitising on every serve means one bug in a serving path exposes the original. Replace the original with the cleaned version at upload time; keep the original only in a quarantined location if you need it for support.
  • sandbox in the CSP. The sandbox directive treats the document as a unique opaque origin with scripts disabled, so even a payload the sanitiser missed cannot read your cookies or call your APIs. Serving from a separate domain adds another layer.

Sanitise, rasterise or reject?

Choosing a handling strategy for uploaded SVGs If users need vector output such as scalable logos or editing, sanitise with an allow-list and serve from an isolated origin with a strict CSP. If users only need the image to display, rasterise to PNG at upload and serve only the bitmap. If SVG is not a product requirement, reject it. Do users need the vector, or just the picture? sanitise + isolate logos that must scale icons in a design tool allow-list, separate origin, CSP sandbox, nosniff rasterise avatars, thumbnails anything shown at fixed size PNG at upload; the XML is never served reject photo galleries document uploads not needed = no risk; tell the user why Rasterising is the safest option that still accepts the file; sanitising keeps vectors at the cost of vigilance.
Only keep SVG as SVG when the product genuinely needs vectors; otherwise pixels are safer and simpler.

Rasterising safely

Rasterisation is not automatically safe: the renderer parses the SVG, and a hostile file can try to exhaust it. Sanitise first even when rasterising — the output is then only the drawing — and bound the work. Set limitInputPixels so a declared width="100000" height="100000" cannot allocate gigabytes, cap density so the rendered size stays reasonable, and run rasterisation in a worker with a memory limit and a timeout, like any other image processing.

librsvg, which Sharp uses, does not execute scripts and does not fetch remote resources by default, which removes the script and SSRF risks. It does still follow <image href> to local files in some configurations; the href hook above removes external references before the renderer ever sees them. Render with the same fonts in every environment — an SVG that uses a font not installed on the worker falls back silently, which is a quality issue rather than a security one, but a confusing one.

SVG processing pipeline at upload time An uploaded SVG passes a size check, an entity check, DOMPurify sanitisation and a root element check. It is then either stored as sanitised SVG for vector use or rasterised to PNG with pixel and density limits. The original is quarantined or discarded. Clean before anything renders it size ≤ 2 MB no DTD DOMPurify svg profile + hooks root check still <svg>? store sanitised SVG rasterise → PNG The original never reaches a public URL; only the sanitised or rasterised output does. Serve the SVG output from a separate origin with a sandboxing CSP as the final layer.
Every path out of the pipeline goes through the sanitiser first.

Keeping the sanitiser honest over time

A sanitiser is a dependency with a security boundary, and it only protects you while it is current. Pin DOMPurify to a version range that receives security releases and let your dependency bot raise upgrades promptly; bypasses are found every year or two and the fixes ship fast. Keep a fixture directory of known payloads — the classic ones above plus anything from published advisories — and run it in CI so an upgrade or a config change that reintroduces a hole fails the build rather than reaching production.

Log what the sanitiser removed. DOMPurify.removed lists every element and attribute it stripped, and recording counts per upload (never the payloads themselves in a log others can read) tells you two things: whether legitimate files are losing content you should allow, and whether someone is probing. A user whose uploads repeatedly lose script elements is not exporting from Figma.

Re-sanitise stored files when your policy changes. If you tighten the allow-list, older files sanitised under the looser rules are still on disk; a background job that re-runs the current sanitiser over stored SVGs — the same pattern used for re-scanning stored files when signatures update — brings them in line.

Finally, remember the rendering side. Sanitising protects the file; how your front end embeds it decides whether a miss matters. A lint rule that forbids innerHTML and dangerouslySetInnerHTML with user SVG content, plus a page-level CSP without 'unsafe-inline' scripts, means a bypass needs to beat three independent defences rather than one.

Configuration gotchas

Gradients and icons disappear after sanitising. Tools like Illustrator reference gradients through xlink:href="#id" or put definitions in <style>. Keep fragment references (the hook does) and allow <style> elements with URL-stripping rather than forbidding them outright.

Files detected as text/xml or text/plain by type sniffing. SVG has no magic bytes; detect it by parsing and checking for an <svg> root in the SVG namespace after optional XML declaration and comments. Treat any XML with an SVG root as SVG regardless of extension.

Sanitised SVG still runs script when inlined into your page. If your front end inserts SVG markup with innerHTML, it is now in your document and your page’s CSP applies, not the one set on the file’s URL. Display user SVGs through <img> only.

sharp throws Input buffer has corrupt header: svgload. The SVG is malformed or uses features librsvg does not support. Treat it as a validation failure with a clear message rather than retrying.

Verification

# Every classic payload is removed. fixtures/xss/ holds one file per payload:
# script element, onload attribute, foreignObject+iframe, javascript: link, external image href.
for f in fixtures/xss/*.svg; do
  node --input-type=module -e "
    import { readFileSync } from 'node:fs';
    import { sanitizeSvg } from './svg.js';
    const r = sanitizeSvg(readFileSync('$f'));
    const bad = /script|onload|foreignobject|javascript:|https?:/i.test(r.svg ?? '');
    console.log('$f', r.ok, r.removed, bad ? 'LEAK' : 'clean');"
done

# Served with the isolating headers.
curl -sI https://usercontent.example.net/svg/9c1f.svg | grep -iE 'content-security-policy|x-content-type-options|content-type'

Frequently Asked Questions

Is a regex-based filter enough?

No. Browsers accept many spellings and structures for the same dangerous construct, and regex filters are routinely bypassed with encoding, case changes, namespaces and malformed markup. Parse with a real DOM and use an allow-list.

Can I serve user SVGs from my main domain if they are sanitised?

You can, but a single sanitiser bypass then becomes XSS on your main origin. A separate cookie-less domain plus a sandboxing CSP turns a bypass into a harmless page on an origin with nothing to steal.

Do PDF and HTML uploads need the same care?

HTML uploads — yes, and more; treat them like SVG or refuse them. PDFs have their own risks (JavaScript, embedded files, malformed structures) covered in validating PDF uploads safely.