Introducing Purifai — and the Benchmark Mistake I Had to Correct

Introducing Purifai — and the Benchmark Mistake I Had to Correct

March 8, 2025

Introducing Purifai — and the Benchmark Mistake I Had to Correct

Correction — 26 July 2026. This post originally reported that Purifai blocked 100% of 64 attack vectors while DOMPurify blocked 62.5%, and concluded Purifai was the more secure library. That comparison was wrong, and the fault was in my benchmark, not in the other libraries.

The scoring rule counted a vector as "blocked" whenever the output was empty. Under that rule a function written as () => '' — one that sanitizes nothing and simply deletes its input — also scores 64/64. The metric was measuring deletion, not safety. It also penalised DOMPurify for correct behaviour, by flagging any output that still contained <svg> or <math>, tags that are in its default allow-list.

I rebuilt the benchmark to parse each sanitizer's output in a real DOM and re-parse it (the round trip where mutation XSS lives), scoring security and fidelity together. Under a fair test, Purifai, DOMPurify, sanitize-html and xss all block 100%. Purifai's genuine advantages are size and zero dependencies — not superior security. The corrected numbers are below; the original claims are struck through rather than deleted.

Cross-site scripting (XSS) is still one of the top risks on the OWASP Top 10. Every time you render user-generated content—comments, rich text, profile bios—you're opening a door. The standard fix is sanitization: strip or escape dangerous HTML before it hits the DOM. There are two different jobs hiding behind that one word. One is preserving safe HTML while removing the dangerous parts — what DOMPurify and sanitize-html do, and the harder problem. The other is emitting untrusted content as plain text, where nothing needs to survive. I built Purifai for the second job, in a footprint small enough for edge runtimes with no DOM available.

Purifai is a zero-dependency strip-to-text sanitizer: it removes all markup rather than allow-listing safe tags. That makes it immune to mutation XSS by construction — nothing survives for a parser to mutate on re-parse — and it's the reason it belongs in a different category from DOMPurify, which exists to keep safe formatting. It's TypeScript-native, 4.1 KB gzipped, needs no DOM, and runs in Node, browsers, and edge runtimes.

Purifai - Ultra-secure HTML sanitizer

Quick Start

Install it:

npm install purifai # or pnpm add purifai

Use it:

import { Purifai } from 'purifai'; // Simple sanitization const clean = Purifai.sanitize('<script>alert("xss")</script>Hello World'); console.log(clean); // "Hello World" // With options for stricter control const safe = Purifai.sanitize(userInput, { maxLength: 10000, allowBasicHtml: false, aggressiveMode: true });

That's it. No DOM, no external dependencies. Just a function that takes dirty HTML and returns safe output.

Why It Matters: The Benchmark

In testing against 64 attack vectors, Purifai blocked 64/64 (100%) while sanitize-html blocked 79.7% and DOMPurify 62.5%. Retracted — see the correction above. That scoring rule rewarded deletion; here is what the same three implementations scored under it:

ImplementationOld metric
Purifai64/64 (100%)
() => '' — deletes everything, sanitizes nothing64/64 (100%)
v => v — identity, no sanitization at all2/64

The corrected benchmark inserts each sanitizer's output into a real DOM (jsdom), serializes and re-parses it, and counts a vector blocked only if neither parse yields a script node, an on* handler, or a dangerous-protocol URL. It reports fidelity alongside security, because a number for one without the other is meaningless:

LibraryCategorySecurityText keptMarkup kept
Purifaistrip-to-text100%100%0% (by design)
DOMPurifypreserve-html100%100%100%
sanitize-htmlpreserve-html100%100%100%
xsspreserve-html100%100%100%

84 attack vectors, including the mutation-XSS and namespace-confusion corpora from cure53 and PortSwigger.

Every maintained sanitizer blocks everything. There is no security gap to win. Purifai's 0% markup retention is its design — it exists for content that must be displayed as text — and that design is why mutation XSS cannot reach it: there is no surviving markup for a parser to mutate.

Why Popular Sanitizers Fail

DOMPurify and sanitize-html are battle-tested and block a lot of XSS. But they're not perfect. Understanding why helps explain what Purifai does differently.

Polyglot Attacks

A polyglot payload is a string that is valid in multiple contexts at once—HTML, JavaScript, SVG, URL—all at the same time. Sanitizers often parse input in one context and miss the fact that the same string, when interpreted elsewhere, executes code.

Example: the "universal XSS polyglot" can trigger XSS in many different HTML contexts. It exploits how browsers parse overlapping tags and context switches:

// This payload bypasses many sanitizers—Purifai blocks it jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()///>\\x3e

It looks like gibberish. But browsers parse it in ways that allow alert() to run. DOMPurify and sanitize-html let some variants through because their parsing model doesn't account for every context switch.

Namespace Confusion and Encoding Bypasses

Namespace confusion exploits HTML5's multiple namespaces (HTML, SVG, MathML). Attackers nest elements so a sanitizer thinks a tag is closed when it isn't:

<form><math><mtext></form><form><mglyph><style></math><img src onerror=alert(1)>

Encoding bypasses use HTML entities, URL encoding, or Unicode escapes to hide malicious content:

  • &#60;script&#62;alert(1)&#60;/script&#62; — HTML entities
  • %3Cscript%3Ealert(1)%3C/script%3E — URL encoded
  • \u003cscript\u003ealert(1)\u003c/script\u003e — Unicode escapes

A sanitizer that only looks for literal <script> will miss these.

How Purifai Approaches the Problem

Purifai uses multi-layer sanitization and context-aware parsing. Instead of relying on a single pass or DOM-based parsing (which can be inconsistent across environments), it:

  1. Normalizes encodings first — Decodes HTML entities, URL encoding, and Unicode escapes before applying rules.
  2. Handles context switches — Tracks when the parser moves between HTML, SVG, script, and style contexts.
  3. Blocks protocol injectionjavascript:, vbscript:, data: URIs are stripped from attributes.
  4. Offers aggressive modeaggressiveMode: true (default) applies stricter rules and fallback checks.

aggressiveMode is the right choice for user-generated content, chat systems, CMS content, or anywhere you can't fully trust the input. Turn it off only if you have a controlled input source and need to preserve more HTML structure.

Key Features

Zero dependencies. No DOM, no jsdom, no cheerio. Minimal bundle size (~12KB) and a tiny attack surface. Works in Node and the browser.

Threat analysis. Use analyze() when you need more than sanitization—logging, blocking, or incident response:

import { analyze } from 'purifai'; const result = analyze('<script>alert("hack")</script>User content'); console.log(result.content); // "User content" console.log(result.hadThreats); // true console.log(result.threatLevel); // "critical" if (result.hadThreats) { console.warn('Potential XSS detected', { level: result.threatLevel }); } broadcast(result.content); // Safe to use

Batch processing. Sanitize multiple strings at once for APIs or content pipelines:

import { sanitizeBatch } from 'purifai'; const cleanData = sanitizeBatch([ '<script>alert(1)</script>Hello', '<img src=x onerror=alert(1)>World', 'Safe content' ]); // ["Hello", "World", "Safe content"]

Danger check. Quick pre-scan with isDangerous() for logging or blocking before full sanitization.

Migrating from DOMPurify or sanitize-html

Purifai is a drop-in replacement in most cases. The API is similar, the options map cleanly, and migration usually takes minutes.

When Migration Makes Sense

  • Security-critical apps — Banking, healthcare, admin panels
  • Polyglot concerns — You've seen or heard of bypasses against your current sanitizer
  • Zero-dependency needs — Smaller bundle, no DOM/jsdom
  • TypeScript-native — Built for TypeScript from the ground up

From DOMPurify

Before:

import DOMPurify from 'dompurify'; const clean = DOMPurify.sanitize(dirty);

After:

import { sanitize } from 'purifai'; const clean = sanitize(dirty);

If you were using DOMPurify in Node with jsdom, you no longer need jsdom—Purifai doesn't use the DOM.

From sanitize-html

Before:

import sanitizeHtml from 'sanitize-html'; const clean = sanitizeHtml(dirty, { allowedTags: ['b', 'i', 'em', 'strong', 'p'], allowedAttributes: { a: ['href'] }, });

After:

import { sanitize } from 'purifai'; const clean = sanitize(dirty, { allowBasicHtml: true, maxLength: 50000, allowedProtocols: ['http', 'https', 'mailto'], aggressiveMode: true });

allowBasicHtml: true enables a curated set of safe inline tags. For "strip everything," use allowBasicHtml: false (default).

Edge Cases

Custom protocols. Purifai defaults to http, https, and mailto. For others (e.g. tel:):

sanitize(dirty, { allowedProtocols: ['http', 'https', 'mailto', 'tel'] });

Max length. Cap input size to avoid DoS:

sanitize(dirty, { maxLength: 10000 });

Batch processing. For many strings (e.g. API request bodies):

import { sanitizeBatch } from 'purifai'; const cleanData = sanitizeBatch(Object.values(request.body));

Testing Strategy

Don't switch cold. Run both sanitizers in parallel during rollout:

  1. Install Purifai alongside your current library.
  2. Add a comparison layer in development or staging:
import { sanitize as purifaiSanitize } from 'purifai'; import DOMPurify from 'dompurify'; function sanitizeWithComparison(input: string) { const purifaiResult = purifaiSanitize(input); const dompurifyResult = DOMPurify.sanitize(input); if (purifaiResult !== dompurifyResult) { console.warn('Sanitizer output differs', { input, purifaiResult, dompurifyResult }); } return purifaiResult; }
  1. Log differences—Purifai may strip more. That's expected and usually desirable.
  2. Run your test suite. Fix any legitimate content that gets over-stripped.
  3. Deploy, then remove the old library and comparison code.

Quick Checklist

  • Install: pnpm add purifai
  • Replace imports: sanitize from purifai
  • Map options: allowBasicHtml, maxLength, allowedProtocols as needed
  • Remove jsdom (if you only had it for DOMPurify)
  • Run both sanitizers in parallel in staging
  • Run tests, fix any over-stripping
  • Deploy and remove old dependency

Wrap-up

Popular sanitizers block a lot—but not everything. Polyglot attacks, encoding bypasses, and namespace confusion exploit the gap between "good enough" and "bulletproof." Purifai was built to close that gap. If you're handling untrusted HTML, it's worth testing your current sanitizer against the same vectors. You might be surprised what gets through.

Purifai is on npm and open source on GitHub. Run the benchmarks yourself—pnpm benchmark in the repo compares against DOMPurify, sanitize-html, and others. If you hit edge cases, the README and GitHub issues are good places to look.