typescript 53 lines · 2 tabs

Sanitize user HTML safely (DOMPurify + JSDOM)

Shared by codesnips Jan 2026
2 tabs
import { JSDOM } from 'jsdom';
import createDOMPurify, { DOMPurifyI } from 'dompurify';

const { window } = new JSDOM('');
const DOMPurify: DOMPurifyI = createDOMPurify(window as unknown as Window);

DOMPurify.addHook('afterSanitizeAttributes', (node) => {
  if (node.tagName === 'A' && node.hasAttribute('target')) {
    node.setAttribute('rel', 'noopener noreferrer');
  }
});

const CONFIG = {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'code', 'pre'],
  ALLOWED_ATTR: ['href', 'title', 'target'],
  ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|#|\/)/i,
  KEEP_CONTENT: true,
};

export function createSanitizer() {
  return function sanitize(dirty: string): string {
    return DOMPurify.sanitize(dirty, CONFIG).trim();
  };
}

export const sanitizeHtml = createSanitizer();
2 files · typescript Explain with highlit

This snippet shows how untrusted HTML from user input is sanitized on the server before being stored or rendered, using DOMPurify bound to a JSDOM window. DOMPurify is a browser library that needs a live DOM to walk and mutate the parsed markup, so on Node it must be wired to a synthetic window — that is exactly what sanitizer.ts does.

In sanitizer.ts, a single JSDOM instance is created once at module load and passed to createDOMPurify, which is far cheaper than spinning up a new window per request. The createSanitizer factory returns a sanitize function preconfigured with an allow-list: ALLOWED_TAGS and ALLOWED_ATTR restrict output to a small set of formatting and link elements, and ALLOWED_URI_REGEXP blocks javascript: and data: URIs that are a classic vector for script injection through href. The key idea of sanitization is that a deny-list is unwinnable — attackers find encodings and edge cases faster than they can be blacklisted — so a strict allow-list of known-safe tags and attributes is used instead.

A hardening hook is registered via addHook('afterSanitizeAttributes'): any anchor that ends up with a target attribute also gets rel="noopener noreferrer", closing the reverse-tabnabbing hole where a linked page can manipulate window.opener. Because RETURN_DOM and friends are left off, sanitize returns a clean HTML string ready to persist.

commentsRouter.ts demonstrates the boundary where this matters. The POST /comments handler treats the raw body as hostile, runs it through sanitizeHtml before anything else, and rejects the request with 422 if sanitization strips the content down to nothing — a cheap way to catch payloads that were pure markup or script. Only the sanitized string is passed to Comment.create, so the database never holds dangerous markup.

The important trade-off is when to sanitize. Sanitizing on input (as shown) keeps stored data clean and makes reads fast, but couples stored content to the current allow-list; sanitizing on output preserves the original but pays the cost on every render. A common pitfall is trusting client-side sanitization alone — it is trivially bypassed by hitting the API directly, so the server pass in commentsRouter.ts is the real security control. Escaping at render time remains a valuable second layer for defense in depth.


Related snips

Share this code

Here's the card — post it anywhere.

Sanitize user HTML safely (DOMPurify + JSDOM) — share card
Link copied