Render User-Generated HTML Safely: Block Comment XSS Risks

40% of web application vulnerabilities trace back to XSS — and your rich text editor is the front door

The numbers don't lie: according to recent industry reports, cross-site scripting (XSS) consistently accounts for nearly 40% of all web application vulnerabilities. That's not a rounding error or a fringe statistic. It means if your application renders user-generated HTML strings, you're standing on the most contested battlefield in web security today.

I'm going to walk you through a scenario I see constantly in the developer tools space. You're building a collaborative document editor — think a Notion-style workspace or a markdown-powered knowledge base — and users need to paste formatted content, embed images, and share code snippets. The moment you decide to render that raw HTML string in the browser, you've opened a door that attackers love to walk through. By the end of this article, you'll have one workable sanitization pipeline that handles this safely without gutting your editor's functionality.

The scenario we're solving

Your users paste content from Word, Google Docs, and random websites. Your backend stores it as an HTML string. Your frontend needs to display it beautifully — bold text, tables, embedded images, the works. But between that paste event and the rendered DOM, there's a gap where a single <script> tag, an onerror handler, or a malicious href attribute can hijack user sessions, steal tokens, and compromise your entire application.

1 payload, 14 common vectors: understanding what you're up against

Let's get concrete. A typical XSS payload isn't just the obvious <script>alert(1)</script> that every tutorial warns you about. Security researchers have catalogued dozens of vectors that bypass naive string filtering. Here's a calculation worth remembering: if your sanitizer only strips <script> tags, you've closed exactly 1 of roughly 14 common attack vectors. The remaining 13 include:

  • Event handler attributes: onload, onerror, onclick — these execute JavaScript without a single script tag
  • JavaScript URIs: href="javascript:alert(document.cookie)" in anchor tags
  • SVG-based payloads: embedded SVGs can carry inline scripts and foreign objects
  • Data URIs: src="data:text/html,..." can execute script content
  • CSS expression injection: legacy but still relevant in certain browser contexts
  • Mutation XSS: payloads that exploit how browsers re-parse HTML after sanitization

The takeaway is simple: string replacement and regex-based filtering are fundamentally inadequate. You need a parser-based approach that understands the DOM, not just pattern matching.

Why regex sanitization fails at scale

I've seen developer teams attempt sanitization with regex patterns like /<script>.*?<\/script>/gi. The problem? Attackers craft inputs like <scr<script>ipt>alert(1)</script>. Your regex strips the inner <script> tag, the browser reassembles the remaining fragments, and the payload executes. One regex, zero protection. This is why parser-based sanitization libraries exist — they construct an actual DOM tree, evaluate each node, and discard anything that doesn't belong.

3 layers of defense: building a sanitization pipeline that actually works

Rather than relying on a single tool, a robust pipeline for safely rendering user-generated HTML strings operates in three distinct layers. Each layer catches what the previous one might miss, and together they reduce XSS risk to near zero.

Layer 1: Server-side sanitization with DOMPurify or sanitize-html

Your first checkpoint runs on the server before the HTML string ever reaches your database. This is non-negotiable. If you only sanitize on the client, an attacker can bypass your frontend entirely and POST raw malicious HTML directly to your API endpoint.

For Node.js backends, sanitize-html is a solid choice. For browser-side processing, DOMPurify is the industry standard — it's used by Facebook, Google, and Microsoft for good reason. Here's a practical configuration that balances security with the formatting your collaborative editor needs:

import DOMPurify from 'dompurify';

const config = {
  ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 
                 'h1', 'h2', 'h3', 'h4', 'table', 'thead', 'tbody', 'tr', 
                 'th', 'td', 'img', 'code', 'pre', 'blockquote'],
  ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
  ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|data:image\/(?:png|jpeg|gif)):)/,
  FORBID_ATTR: ['style', 'onerror', 'onload', 'onclick']
};

const cleanHTML = DOMPurify.sanitize(userInput, config);

This configuration permits the formatting tags your editor needs while blocking inline styles (which can carry CSS-based attacks), JavaScript URIs, and all event handler attributes. The URI regex restricts links to HTTPS, mailto, and specific image data URIs — closing the javascript: vector entirely.

Layer 2: Content Security Policy as your safety net

Even with perfect sanitization, a Content Security Policy (CSP) provides critical defense-in-depth. If a sanitization bypass is discovered tomorrow — and they have been discovered in the past — your CSP prevents script execution regardless. The minimum viable CSP for an application rendering user HTML:

Content-Security-Policy: 
  default-src 'self';
  script-src 'self' 'nonce-{random}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  object-src 'none';
  base-uri 'none';

The object-src 'none' and base-uri 'none' directives alone eliminate entire classes of XSS vectors. The nonce-based script-src ensures only your explicitly tagged scripts execute. This is your safety net — it doesn't replace sanitization, but it catches what sanitization might miss.

Layer 3: Safe rendering in the framework

The final layer is how you actually inject the sanitized HTML string into the DOM. This is where framework-specific knowledge matters. Each major frontend framework handles HTML rendering differently, and using the wrong method can reintroduce XSS risk even after sanitization.

For React, use dangerouslySetInnerHTML — but only with sanitized content. The name is intentionally frightening. For Vue, use v-html with the same caveat. For Angular, use [innerHTML] with DomSanitizer. The key principle: never bypass these mechanisms, and never render sanitized HTML using document.write() or direct innerHTML assignment outside the framework's awareness.

0 false positives: tuning your sanitizer without breaking user content

A sanitizer that's too aggressive is almost as bad as no sanitizer at all. If users paste a table from Google Sheets and your sanitizer strips it, they'll find workarounds — and those workarounds are rarely secure. The goal is zero false positives: legitimate content passes through untouched, malicious content gets neutralized.

The calculation here is straightforward. Test your sanitization pipeline against two datasets: a corpus of known XSS payloads (the OWASP XSS Filter Evasion Cheat Sheet contains over 90 test cases) and a corpus of legitimate user content from your actual application. If your sanitizer blocks any legitimate content, adjust your allowed tags and attributes. If it passes any XSS payload, tighten your configuration.

Handling edge cases in collaborative editors

Collaborative document editors have a unique challenge: content changes in real-time as multiple users type. If you sanitize on every keystroke, you'll kill performance. If you sanitize only on save, you risk rendering unsanitized content during the editing session itself. The solution is a two-phase approach: sanitize aggressively on paste events (before content enters the editor's internal model), and sanitize again on persistence (before writing to the database). This gives you real-time editing performance without sacrificing security.

1 audit per quarter: maintaining your sanitization pipeline over time

Security isn't a set-it-and-forget-it task. DOMPurify has had bypasses discovered and patched. Browser parsing quirks change. New attack vectors emerge. A quarterly audit of your sanitization pipeline involves three steps: update your sanitization library to the latest version, re-run your XSS payload test suite, and review your allowed tags list against your current feature requirements. This takes roughly 2 hours per quarter and prevents the kind of vulnerability that can compromise every user in your application.

The bottom line for anyone building tools that render user-generated HTML: sanitization is not optional, it's not a checkbox, and it's not something you can handle with a regex. A parser-based sanitization library, a strict CSP, and framework-aware rendering — that's your pipeline. Three layers, near-zero XSS risk, and your collaborative editor stays both functional and secure.

Frequently Asked Questions

How do I safely render user-generated HTML in React?

React automatically escapes strings to prevent XSS attacks, but if you need to render raw HTML, you must use the `dangerouslySetInnerHTML` attribute. To do this safely, always pass the HTML string through a robust sanitizer like DOMPurify before assigning it to this attribute. This ensures malicious scripts are stripped out before rendering.

What is the best way to sanitize HTML strings in JavaScript?

The most recommended approach is to use a trusted library like DOMPurify, which removes dangerous payloads while preserving safe HTML tags. Simply pass your user-generated HTML string through `DOMPurify.sanitize(html)` before injecting it into the DOM. This effectively neutralizes XSS vectors like `<script>` tags and malicious event handlers.

Does React automatically prevent XSS?

Yes, React automatically escapes variables embedded in JSX, which protects against standard XSS attacks. However, if you bypass this mechanism using `dangerouslySetInnerHTML` or attach user input to `href` attributes, you must manually sanitize the data. Always validate and clean user inputs before rendering them in these vulnerable areas.

How can I allow certain HTML tags but prevent XSS?

You can use an allowlist approach with a sanitization library like DOMPurify or sanitize-html to specify exactly which tags and attributes are permitted. For example, you can configure the sanitizer to allow formatting tags like `<b>` and `<i>` while stripping out `<script>` tags and `onload` attributes. This lets you display rich text formatting without exposing your application to XSS vulnerabilities.

What is DOMPurify and why should I use it?

DOMPurify is a fast, DOM-only XSS sanitizer for HTML, MathML, and SVG that ensures your user-generated content is safe to render. It parses the HTML string and removes any dangerous code, returning a clean string that can be safely inserted into the document. It is widely considered the industry standard for preventing DOM-based XSS attacks in JavaScript applications.

Is it safe to use innerHTML with user input?

No, using `innerHTML` directly with user input is a major XSS risk because it will execute any embedded scripts or malicious code. If you absolutely must use `innerHTML`, you are required to sanitize the string first using a library like DOMPurify. Failing to do so gives attackers the ability to hijack user sessions and steal sensitive data.

How do I escape HTML characters in JavaScript?

Escaping HTML converts special characters like `<` and `>` into their corresponding HTML entities, preventing the browser from interpreting them as executable code. You can achieve this by replacing these characters using simple regex or by relying on framework features that escape content by default. If you only need to display text and not render complex HTML tags, escaping is the safest route.

How do modern frameworks like Angular and Vue handle XSS?

Frameworks like Angular and Vue use native templating syntax that automatically escapes dynamic content to prevent XSS vulnerabilities. Angular provides the `DomSanitizer` service for specific cases where you need to render untrusted HTML safely, while Vue uses the `v-html` directive which requires pre-sanitized input. Developers must explicitly bypass these built-in protections to render raw HTML, which necessitates external sanitization.

What is a Content Security Policy (CSP) and does it stop XSS?

A Content Security Policy (CSP) is an HTTP header that restricts the sources from which scripts and other resources can be loaded on your website. While it is not a replacement for sanitizing user-generated HTML, it acts as a strong defense-in-depth layer by mitigating the impact of XSS attacks. By disabling inline scripts, a strict CSP can prevent malicious injected code from executing.

Can sanitizing HTML break my website's styling?

Yes, overly aggressive sanitization can strip out necessary classes or inline styles required for your layout. To prevent this, configure your sanitizer with a strict allowlist of accepted CSS properties and class names that match your design system. This allows you to maintain your UI integrity while ensuring no dangerous style-based XSS vectors remain.