Preventing Stored XSS: Sanitizing HTML in Node.js
Learn how to sanitize HTML in Node.js to prevent stored XSS. Stop malicious scripts at the source using DOMPurify server-side for robust security.
When you allow users to submit rich text—like in a comment section, a profile bio, or a CMS—you’re essentially opening a door to stored XSS. I remember fixing a production bug where a simple <img src=x onerror=alert(1)> injection bypassed our "sanitization" logic because we were using a fragile regex filter. It’s a common trap, and it happens because trying to parse HTML with regular expressions is fundamentally broken.
If you don't neutralize that input before it hits your database, every user who views that content executes the attacker's script. Here is how to actually fix it.
Why Regex Sanitization Fails
Early in my career, I thought a str.replace(/<script.*?>.*?<\/script>/gi, '') was sufficient. It isn't. Attackers use obfuscation, nested tags, and event handlers that don't need <script> tags to execute code.
For example, onmouseover, onerror, and onload attributes are valid HTML entry points for malicious payloads. When you try to write your own parser to catch these, you'll eventually miss a case that a browser will happily interpret as executable code.
The Right Way: DOMPurify Server-Side
Instead of reinventing the wheel, use a battle-tested library. DOMPurify is the industry standard for this. While it was built for the browser, you can easily run it in Node.js using jsdom.
Step 1: Install Dependencies
You'll need dompurify and jsdom to create the virtual DOM environment.
Bashnpm install dompurify jsdom
Step 2: Implement the Sanitizer
Create a utility module to handle the cleaning process. I like to keep this logic isolated so I can update the configuration globally if needed.
JAVASCRIPTconst createDOMPurify = require(CE9178">'dompurify'); const { JSDOM } = require(CE9178">'jsdom'); const window = new JSDOM(CE9178">'').window; const DOMPurify = createDOMPurify(window); function sanitizeHTML(dirty) { // Use a whitelist approach return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [CE9178">'b', CE9178">'i', CE9178">'em', CE9178">'strong', CE9178">'p', CE9178">'br'], ALLOWED_ATTR: [] // Strip all attributes like CE9178">'onerror' }); } // Usage const clean = sanitizeHTML("<p>Hello <img src=x onerror=alert(1)></p>"); console.log(clean); // "<p>Hello </p>"
By setting ALLOWED_ATTR to an empty array, you kill all potential vectors like onclick or href="javascript:..." while keeping the formatting tags you actually want.
Beyond Sanitization
Sanitization is your primary defense against stored XSS, but it shouldn't be your only one. Remember that security is layered. If you're building a full-stack application, consider these additional practices:
- Content Security Policy (CSP): Use a strict CSP header to prevent the browser from executing inline scripts even if a malicious payload slips through your filter.
- MIME-Sniffing Prevention: Always set
X-Content-Type-Options: nosniffheaders to stop browsers from misinterpreting non-executable files as scripts, as discussed in my guide on MIME-sniffing prevention: Hardening X-Content-Type-Options Headers. - Data Minimization: Don't store more than you need. If you don't need to save raw HTML, store plain text and render it as HTML on the client side using safe templating engines.
Comparison: Sanitization Strategies
| Strategy | Security Level | Complexity | Performance |
|---|---|---|---|
| Regex Stripping | Very Low | Low | High |
| Manual Blacklisting | Low | Medium | Medium |
| DOMPurify (Whitelist) | Very High | Low | Medium |
Frequently Asked Questions
Can I just use a library like sanitize-html instead of jsdom + dompurify?
Yes, sanitize-html is also a popular choice for Node.js. The logic remains the same: use a library that builds an AST (Abstract Syntax Tree) to parse the HTML properly rather than using regex.
Does this stop XSS in my React/Next.js frontend?
Using these tools on the server ensures the data in your database is clean. However, you should still avoid using dangerouslySetInnerHTML in React whenever possible. If you must use it, ensure the input is sanitized exactly as shown above.
What if I need to allow links?
If you need to allow links, update your ALLOWED_ATTR to include href and use the ADD_TAGS or ALLOWED_TAGS configuration to allow <a>. Always pair this with a validation step to ensure the href doesn't start with javascript:.
Final Thoughts
I’ve learned the hard way that sanitization is not a "set it and forget it" task. As browser features evolve, so do the ways attackers find to exploit them. Using a whitelist-based library like DOMPurify is the most robust way to protect your users from stored XSS.
If you're still struggling with securing your infrastructure, feel free to check out my notes on VPS Server Setup, Deployment & Hardening for a more comprehensive look at keeping your server environment secure. Next time, I’d probably look into implementing a stricter Type-level security wrapper to catch these issues before the code even compiles.