The problem
Every time you render user-generated content — comments, rich text, profile bios — you open a door. Sanitization is the standard fix, but the popular libraries leak. Polyglot payloads, encoding bypasses, and namespace-confusion techniques slip past both DOMPurify and sanitize-html.
Purifai exists to close that gap: an ultra-secure, zero-dependency HTML sanitizer that is TypeScript-native, roughly 12KB, and runs in both Node.js and the browser.
The benchmark was measuring the wrong thing
The original suite scored a vector as "blocked" when the output was empty. I tested that rule against three implementations on the same 64 vectors:
| Implementation | Score |
|---|---|
| Purifai | 64/64 (100%) |
() => '' — deletes everything, sanitizes nothing | 64/64 (100%) |
v => v — identity, no sanitization at all | 2/64 |
A function returning the empty string ties a perfect score. The metric rewarded
deletion, not safety — and it penalised competitors for correct behaviour, since
it flagged any surviving <svg> or <math> even though those are in DOMPurify's
allow-list. So I replaced it.
Results
The new benchmark inserts each sanitizer's output into a real DOM (jsdom), then serializes and re-parses it — the round trip where mutation XSS lives — and scores two axes, because security alone is meaningless without knowing what survived:
| Library | Category | Security | Text kept | Markup kept |
|---|---|---|---|---|
| Purifai | strip-to-text | 100% | 100% | 0% (by design) |
| DOMPurify | preserve-html | 100% | 100% | 100% |
| sanitize-html | preserve-html | 100% | 100% | 100% |
| xss | preserve-html | 100% | 100% | 100% |
84 attack vectors (OWASP, PortSwigger, cure53 mXSS/namespace-confusion corpora).
The honest read: on security there is no headroom — every maintained sanitizer blocks everything. Purifai isn't "more secure than DOMPurify". It's smaller (4.1 KB gzipped), dependency-free, and needs no DOM, so it runs in edge and worker runtimes where jsdom-backed sanitizers can't.
Using it
npm install purifai
import { Purifai } from 'purifai'; const clean = Purifai.sanitize('<script>alert("xss")</script>Hello World'); // -> "Hello World" const safe = Purifai.sanitize(userInput, { maxLength: 10000, allowBasicHtml: false, aggressiveMode: true, });
No DOM shim, no external dependencies — a function that takes dirty HTML and returns safe output.
What I'd highlight
The most useful work was auditing my own claims. Fuzzing with jsdom as an oracle —
generated payloads checked against six invariants rather than against another
regex — found a defect no curated corpus had: the cleanup pass used
/on\w+\s*=/gi, and since \w matches digits with no word boundary, the
pipeline's own earlier deletions could turn confirm(1)href= into
confirm1href=, whose onfirm1href= substring then matched. Removal was
manufacturing matches absent from the input. Fixing it meant a word boundary
plus iterating filters to a fixpoint — which makes idempotence a security property
for a delete-based design, not a nicety.
The broader lesson: a benchmark you wrote yourself will flatter you unless you
calibrate it. Adding () => '' and v => v as rows is what exposed the original
metric, and it's now a permanent part of the suite so the number can be audited by
anyone reading it.
