saascode

Sanitize rich-text HTML where it becomes executable

Stored rich text becomes executable when React renders it as raw HTML. Use a narrow allowlist at the render boundary and test hostile attributes.

academy · aug 21, 2026 · 2 min read

A rich-text editor can make unsafe HTML look trustworthy. The content came through your own UI, passed validation, and now sits in your database. None of that makes it safe to execute in another user's browser.

In one support product, two rich-text surfaces—knowledge articles and ticket replies—stored editor-generated HTML and later passed it to dangerouslySetInnerHTML. A staff author or customer with reply access could save an event handler such as onerror and have it run for every viewer. That is stored cross-site scripting: the payload waits in the database until a more privileged user opens the page.

React's name for the API is an honest warning. Its documentation says to use dangerouslySetInnerHTML only with trusted and sanitized data.

Sanitize at the execution boundary

Use a maintained HTML sanitizer with an explicit allowlist that matches the editor features you support:

import sanitizeHtml from 'sanitize-html'

function RichText({ html }: { html: string }) {
  const safeHtml = sanitizeHtml(html, {
    allowedTags: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'h2', 'h3'],
    allowedAttributes: {
      a: ['href'],
    },
    allowedSchemes: ['http', 'https', 'mailto'],
  })

  return <article dangerouslySetInnerHTML={{ __html: safeHtml }} />
}

The exact list is a product decision. If the editor supports images, tables, or code blocks, add only the tags and attributes those features need. Do not copy a permissive default and assume it matches your threat model. URL-bearing attributes deserve protocol checks; style attributes deserve particular caution.

Sanitizing on write can keep stored content consistent, but it is not the final security boundary. Old rows, imports, migrations, direct database access, and sanitizer upgrades can all bypass or outlive a write-time rule. Sanitize again where stored markup becomes executable. If the same content is rendered in email or another client, that is a separate boundary with its own policy.

Test payloads, not package presence

Finding a sanitizer dependency in package.json proves nothing about the render path. Trace every dangerouslySetInnerHTML and innerHTML call back to its input, then run hostile cases through the exact configuration:

<img src=x onerror=alert(1)>
<a href="javascript:alert(1)">open</a>
<svg onload=alert(1)></svg>

The expected result is not merely “no alert appeared once.” Assert that disallowed tags, event attributes, and unsafe schemes are absent from the sanitized string.

Two surfaces were enough to expose the pattern in the original build. Once raw stored HTML appears in one place, audit every renderer. The dangerous unit is the boundary, not the table where the string happened to live.

end