ASVS 5.0 V1.3.1 (Level 1) requires that all untrusted HTML from WYSIWYG editors and similar sources is sanitized with a well known, maintained HTML sanitization library or a framework feature. Writing your own filter with regular expressions does not meet it.
Why it matters
Rich text editors produce HTML, and HTML can carry script. A comment body with <img src=x onerror=...> or an <a href="javascript:..."> runs in the browser of everyone who views it, and that includes administrators (CWE-79). Output encoding does not help here, because encoding would show the tags as text and destroy the formatting you wanted to allow. You need to keep a safe subset of tags and drop the rest.
HTML parsing is full of quirks: nested tags, broken markup that browsers repair, namespaces, entity tricks and mutation behavior. That is why this job belongs to a library that tracks them.
How to implement it
Sanitize on the server before storing or before rendering, and sanitize again on the client if you insert HTML into the DOM there. Use an allowlist: name the tags and attributes you accept, and everything else goes.
Browser or Node with DOMPurify:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'blockquote', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'title'],
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
});
element.innerHTML = clean;
On Node, run DOMPurify with jsdom as the window. Python with nh3 (a binding to a Rust sanitizer):
import nh3
clean = nh3.clean(
dirty_html,
tags={"p", "br", "strong", "em", "ul", "ol", "li", "a", "blockquote", "code", "pre"},
attributes={"a": {"href", "title"}},
url_schemes={"http", "https", "mailto"},
link_rel="noopener noreferrer",
)
Java has the OWASP Java HTML Sanitizer, and .NET has HtmlSanitizer. Pick one that is actively maintained and keep it updated, since sanitizer bypasses are published regularly.
A few practical rules:
- Sanitize at the point where you decide the content is trusted, and do not modify the output afterwards. Changing sanitized HTML with string operations can reintroduce problems.
- Store the raw input as well if you may want to re-sanitize with a newer library version later.
- Add a Content Security Policy as a second layer. It does not replace the sanitizer.
- Keep the allowlist small. Every tag you permit is attack surface, and
style,iframe,svg,formandobjectshould usually stay off.
Rich text editors that store a JSON document model (instead of HTML) reduce the risk, because you render known node types yourself. Prefer that where the editor supports it.
How to test it
Collect a set of known payloads and run them through your real save and display path:
<script>alert(1)</script><img src=x onerror=alert(1)><a href="javascript:alert(1)">x</a><svg><script>alert(1)</script></svg><p style="background:url(javascript:alert(1))">x</p><div><p>unclosedfor broken markup
Check the stored value and the rendered DOM. No script elements, event handler attributes or javascript URLs may remain, and legitimate formatting such as bold text and lists should still render. Add these payloads to your automated test suite so an upgrade or config change cannot silently weaken the filter.
Then check the dependency itself. Sanitizers have had bypass advisories, so confirm your version is current. Safeguard has a free in browser dependency scan at gold.safeguard.sh/scan that you can run on your manifest to see if the sanitizer version you ship has known issues.
Common mistakes
- Building a filter with regular expressions that strip
scripttags. Attackers use event handlers, nested tags and encodings instead. - Sanitizing in the browser only. Attackers post directly to the API.
- Allowing
classandstyleattributes without thinking about what they can do. - Using a sanitizer with default settings that permit more than the feature needs.
- Sanitizing before a transformation (such as Markdown rendering), then rendering after. Sanitize the final HTML.
- Never updating the library.
Try it yourself
Write a regression suite for your sanitizer that you can run in a few seconds. Keep payloads in a file so security researchers and developers can add new ones.
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import { test, expect } from 'vitest';
const purify = DOMPurify(new JSDOM('').window);
const payloads = [
'<img src=x onerror=alert(1)>',
'<a href="javascript:alert(1)">x</a>',
'<svg><script>alert(1)</script></svg>',
'<iframe src="https://evil.example"></iframe>',
];
test.each(payloads)('removes active content: %s', (p) => {
const out = purify.sanitize(p);
expect(out).not.toMatch(/onerror|javascript:|<script|<iframe/i);
});
test('keeps formatting', () => {
expect(purify.sanitize('<p><strong>hi</strong></p>')).toContain('<strong>');
});
The last test matters as much as the others. A sanitizer that deletes everything passes the security tests and breaks the feature, and people respond by loosening it.
Quick check
Why is output encoding not enough for rich text?
Encoding turns every tag into visible text, which removes the formatting you wanted to keep. A sanitizer keeps a safe subset of tags and removes the dangerous parts.
Where should sanitization run?
On the server, on the final HTML, before storage or rendering. Client side sanitizing is an extra layer, not the control.