purifai 2.0.3 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -383
- package/benchmark/results/v3.json +2767 -0
- package/dist/index.cjs +14 -475
- package/dist/index.d.cts +2 -189
- package/dist/index.d.ts +2 -189
- package/dist/index.js +14 -443
- package/dist/src/api.d.ts +7 -0
- package/dist/src/config.d.ts +18 -0
- package/dist/src/contracts.d.ts +40 -0
- package/dist/src/entities.d.ts +15 -0
- package/dist/src/formatter.d.ts +36 -0
- package/dist/src/generated/entities.d.ts +3 -0
- package/dist/src/policy.d.ts +15 -0
- package/dist/src/scanner.d.ts +71 -0
- package/dist/src/session.d.ts +27 -0
- package/docs/benchmarks/v3.md +92 -0
- package/docs/migration-v3.md +95 -0
- package/package.json +42 -42
package/README.md
CHANGED
|
@@ -1,447 +1,268 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
[
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
(jsdom), then serialized and re-parsed — the round trip where mutation XSS lives.
|
|
26
|
-
A vector counts as having no executable output only if *neither* parse yields a
|
|
27
|
-
script node, an `on*` handler, or a dangerous-protocol URL. Exact-text fidelity
|
|
28
|
-
and raw-container removal are separate measurements so deletion cannot masquerade
|
|
29
|
-
as quality.
|
|
30
|
-
|
|
31
|
-
| Library | Category | No executable output* | Exact text | Markup kept | Raw body removed | Median ops/sec† | p95 µs/op† |
|
|
32
|
-
|---------|----------|----------------------|------------|-------------|------------------|----------------|------------|
|
|
33
|
-
| **Purifai.sanitize** | strip-to-text | **100%** | **100%** | 0% *(by design)* | **100%** | **526,709** | **2.050** |
|
|
34
|
-
| Purifai.escape | encode-as-text | 100% | 40% | 0% | 0% | 596,421 | 3.627 |
|
|
35
|
-
| striptags | strip-to-text | 100% | 100% | 0% | 20% | 633,697 | 1.836 |
|
|
36
|
-
| DOMPurify (jsdom) | preserve-safe-html | 100% | 100% | 100% | 60% | 1,686 | 698.853 |
|
|
37
|
-
| sanitize-html | preserve-safe-html | 100% | 100% | 100% | 60% | 88,492 | 14.731 |
|
|
38
|
-
| xss | preserve-safe-html | 100% | 100% | 100% | 0% | 335,448 | 4.079 |
|
|
39
|
-
| rehype-sanitize | preserve-safe-html | 100% | 100% | 100% | 40% | 24,522 | 44.439 |
|
|
40
|
-
| escape-html | encode-as-text | 100% | 40% | 0% | 0% | 2,238,804 | 0.452 |
|
|
41
|
-
| validator.escape | encode-as-text | 100% | 40% | 0% | 0% | 735,565 | 1.494 |
|
|
42
|
-
| entities.escapeUTF8 | encode-as-text | 100% | 40% | 0% | 0% | 1,208,824 | 1.453 |
|
|
43
|
-
| html-entities | encode-as-text | 100% | 40% | 0% | 0% | 869,440 | 1.203 |
|
|
44
|
-
| he.escape | encode-as-text | 100% | 40% | 0% | 0% | 952,948 | 1.242 |
|
|
45
|
-
|
|
46
|
-
84 attack vectors (OWASP, PortSwigger, cure53 corpora) · 15 benign documents.
|
|
47
|
-
|
|
48
|
-
\* This is an observed corpus result, not a security guarantee. † Throughput is
|
|
49
|
-
the median of seven samples after warm-up; p95 is the slowest of those seven
|
|
50
|
-
local samples. Snapshot captured on 2026-08-02
|
|
51
|
-
with Node 26.3.0 on Apple Silicon. It varies by hardware and runtime and should
|
|
52
|
-
only be compared within the same category. The command above is the source of
|
|
53
|
-
truth. HTML encoders safely display the original markup as text; unlike Purifai
|
|
54
|
-
and striptags, they do not turn markup-bearing input into clean reader text.
|
|
55
|
-
|
|
56
|
-
Purifai is not a drop-in replacement for a safe-HTML-preserving sanitizer. Its
|
|
57
|
-
measured advantages are a bounded scanner, zero runtime dependencies, and no DOM
|
|
58
|
-
requirement for the narrower strip-to-text job.
|
|
59
|
-
|
|
60
|
-
Within the strip-to-text rows, striptags is smaller and faster in this snapshot;
|
|
61
|
-
Purifai's differentiation is exact benign text together with full raw-container
|
|
62
|
-
body removal and bounded malformed-input scaling.
|
|
63
|
-
|
|
64
|
-
## 🚀 Why Purifai?
|
|
65
|
-
|
|
66
|
-
### ✅ Security boundaries
|
|
67
|
-
- Produces no executable output in the current 84-vector DOM re-parse corpus
|
|
68
|
-
- Uses a forward-only scanner with measured near-linear adversarial scaling
|
|
69
|
-
- Removes scriptable/raw-text containers together with their bodies
|
|
70
|
-
- Rejects invalid numeric entity scalars instead of emitting control characters
|
|
71
|
-
- Contextual encoders for HTML body, attribute, and URL contexts
|
|
72
|
-
|
|
73
|
-
### ⚡ High Performance
|
|
74
|
-
- **Fast processing** - optimized algorithms for high-throughput applications
|
|
75
|
-
- **Zero dependencies** - minimal bundle size and attack surface
|
|
76
|
-
- **TypeScript native** with full type definitions
|
|
77
|
-
- **Node.js and Browser** compatible
|
|
78
|
-
|
|
79
|
-
### 🎯 Developer Friendly
|
|
80
|
-
- Simple API with intelligent defaults
|
|
81
|
-
- Detailed threat analysis and reporting
|
|
82
|
-
- Batch processing support
|
|
83
|
-
- Comprehensive documentation
|
|
84
|
-
|
|
85
|
-
## Test coverage
|
|
86
|
-
|
|
87
|
-
The repository exercises 64 classic and 20 modern mutation/namespace vectors,
|
|
88
|
-
seeded fuzzing, real-browser parsing, malformed raw-text containers, URL context
|
|
89
|
-
validation, idempotence, and adversarial inputs from 2–128 KiB. A passing corpus
|
|
90
|
-
is regression evidence, not proof against every future browser or payload.
|
|
91
|
-
|
|
92
|
-
## 🛠️ Installation
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
npm install purifai
|
|
96
|
-
# or
|
|
97
|
-
yarn add purifai
|
|
98
|
-
# or
|
|
99
|
-
pnpm add purifai
|
|
1
|
+
> Stream untrusted HTML into clean, readable text with bounded working memory.
|
|
2
|
+
> No DOM. No document tree. No runtime dependencies.
|
|
3
|
+
|
|
4
|
+
# Purifai
|
|
5
|
+
|
|
6
|
+
[npm](https://www.npmjs.com/package/purifai) ·
|
|
7
|
+
[Project notes](https://worksonmy.dev/projects/purifai) ·
|
|
8
|
+
[Runnable examples](https://github.com/moji2002/purifai/tree/main/examples) ·
|
|
9
|
+
[Issues](https://github.com/moji2002/purifai/issues)
|
|
10
|
+
|
|
11
|
+
Purifai is a fixed-policy HTML-to-readable-text converter for servers, browsers,
|
|
12
|
+
and edge runtimes. It incrementally removes non-reader bodies, decodes the full
|
|
13
|
+
WHATWG character-reference set, and preserves useful structure such as headings,
|
|
14
|
+
paragraphs, lists, links, image alternatives, code, and simple tables.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { toText } from 'purifai';
|
|
18
|
+
|
|
19
|
+
const text = toText(
|
|
20
|
+
'<script>alert(1)</script><h2>Release</h2><ul><li>Fast</li></ul>',
|
|
21
|
+
);
|
|
22
|
+
// Release
|
|
23
|
+
//
|
|
24
|
+
// - Fast
|
|
100
25
|
```
|
|
101
26
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
### Basic Usage
|
|
27
|
+
A flat tag remover can leak `alert(1)` from the script body and collapse the
|
|
28
|
+
remaining text. Purifai drops that body and formats the reader content.
|
|
105
29
|
|
|
106
|
-
|
|
107
|
-
|
|
30
|
+
The output is a JavaScript string, not safe HTML. Use one of these supported
|
|
31
|
+
sinks:
|
|
108
32
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
console.log(clean); // "Hello World"
|
|
33
|
+
```ts
|
|
34
|
+
import { escapeHtmlText, toText } from 'purifai';
|
|
112
35
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
maxLength: 10000
|
|
116
|
-
});
|
|
36
|
+
element.textContent = toText(untrustedHtml);
|
|
37
|
+
element.innerHTML = escapeHtmlText(toText(untrustedHtml));
|
|
117
38
|
```
|
|
118
39
|
|
|
119
|
-
|
|
40
|
+
Prefer `textContent`. `escapeHtmlText` exists for an HTML text context only; it
|
|
41
|
+
does not make a value safe for an attribute, URL, JavaScript, CSS, or template
|
|
42
|
+
source.
|
|
120
43
|
|
|
121
|
-
|
|
122
|
-
import { analyze } from 'purifai';
|
|
44
|
+
## Why it exists
|
|
123
45
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
console.log(result.content); // "User content"
|
|
127
|
-
console.log(result.hadThreats); // true
|
|
128
|
-
console.log(result.threatLevel); // "critical"
|
|
129
|
-
console.log(result.processingTime); // 0.023 (ms)
|
|
130
|
-
```
|
|
46
|
+
Purifai targets one narrow intersection:
|
|
131
47
|
|
|
132
|
-
|
|
48
|
+
- readable extraction instead of flat deletion;
|
|
49
|
+
- deterministic input, output, nesting, and retained-token limits;
|
|
50
|
+
- chunk-invariant Web `TransformStream` conversion;
|
|
51
|
+
- no DOM, tree, Node built-in, or runtime dependency; and
|
|
52
|
+
- one side-effect-free artifact across server, browser, and edge runtimes.
|
|
133
53
|
|
|
134
|
-
|
|
135
|
-
|
|
54
|
+
It does not preserve markup and does not classify a user's intent. If either is
|
|
55
|
+
your requirement, use a tool designed for that different job.
|
|
136
56
|
|
|
137
|
-
|
|
138
|
-
'<script>alert(1)</script>Hello',
|
|
139
|
-
'<img src=x onerror=alert(1)>World',
|
|
140
|
-
'Safe content'
|
|
141
|
-
];
|
|
57
|
+
## Install
|
|
142
58
|
|
|
143
|
-
|
|
144
|
-
|
|
59
|
+
```sh
|
|
60
|
+
npm install purifai
|
|
145
61
|
```
|
|
146
62
|
|
|
147
|
-
|
|
63
|
+
Purifai v3 requires Node.js 22 or newer when used in Node.
|
|
148
64
|
|
|
149
|
-
|
|
150
|
-
import { isDangerous } from 'purifai';
|
|
65
|
+
## API
|
|
151
66
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
```
|
|
67
|
+
### `toText(html, options?)`
|
|
68
|
+
|
|
69
|
+
Converts one string and returns readable text. A breached limit throws a
|
|
70
|
+
`PurifaiLimitError`.
|
|
158
71
|
|
|
159
|
-
|
|
72
|
+
```ts
|
|
73
|
+
import { toText } from 'purifai';
|
|
160
74
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
75
|
+
const text = toText('<h1>Guide</h1><p>Start here.</p>', {
|
|
76
|
+
layout: 'readable',
|
|
77
|
+
links: 'label-and-url',
|
|
78
|
+
images: 'alt',
|
|
79
|
+
baseUrl: 'https://docs.example/',
|
|
80
|
+
limits: { input: 1_000_000, output: 250_000, depth: 64, token: 65_536 },
|
|
81
|
+
});
|
|
82
|
+
```
|
|
164
83
|
|
|
165
|
-
|
|
166
|
-
import { escape, escapeAttribute, escapeUrl } from 'purifai';
|
|
84
|
+
### `convert(html, options?)`
|
|
167
85
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// "if (a<b && c>d) return;"
|
|
86
|
+
Returns the text plus a frozen conversion report. It is the only entry point
|
|
87
|
+
that can deliberately return a bounded prefix instead of throwing.
|
|
171
88
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
`<div title="${escapeAttribute(userInput)}">`;
|
|
89
|
+
```ts
|
|
90
|
+
import { convert } from 'purifai';
|
|
175
91
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
```
|
|
92
|
+
const result = convert(largeHtml, {
|
|
93
|
+
limits: { output: 20_000 },
|
|
94
|
+
overflow: 'truncate',
|
|
95
|
+
});
|
|
181
96
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
```typescript
|
|
189
|
-
interface PurifaiOptions {
|
|
190
|
-
/** Maximum input length (default: 1MB) */
|
|
191
|
-
maxLength?: number;
|
|
192
|
-
|
|
193
|
-
/** Subset of built-in safe protocols: http, https, mailto */
|
|
194
|
-
allowedProtocols?: string[];
|
|
195
|
-
|
|
196
|
-
/** @deprecated Retained for compatibility; strip-to-text is always used. */
|
|
197
|
-
aggressiveMode?: boolean;
|
|
198
|
-
}
|
|
97
|
+
result.text;
|
|
98
|
+
result.truncatedBy; // 'output' or null
|
|
99
|
+
result.scanComplete; // false after truncation
|
|
100
|
+
result.consumedInputCodeUnits;
|
|
101
|
+
result.outputCodeUnits;
|
|
102
|
+
result.droppedContainers; // e.g. { script: 2, style: 1 }
|
|
199
103
|
```
|
|
200
104
|
|
|
201
|
-
|
|
202
|
-
|
|
105
|
+
Truncation is explicit, deterministic, and never emits half of a UTF-16
|
|
106
|
+
surrogate pair. When multiple limits meet at the same point, the first observed
|
|
107
|
+
limit is reported.
|
|
203
108
|
|
|
204
|
-
|
|
109
|
+
### `createTextTransform(options?)`
|
|
205
110
|
|
|
206
|
-
|
|
207
|
-
|
|
111
|
+
Returns a native `TransformStream<string, string>` with a `result` promise. The
|
|
112
|
+
stream uses the same state machine and produces exactly the same joined text as
|
|
113
|
+
`toText`, regardless of chunk boundaries.
|
|
208
114
|
|
|
209
|
-
```
|
|
210
|
-
import
|
|
211
|
-
import assert from 'node:assert/strict';
|
|
212
|
-
import { sanitize, escape, escapeUrl } from 'purifai';
|
|
115
|
+
```ts
|
|
116
|
+
import { createTextTransform } from 'purifai';
|
|
213
117
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
assert.equal(escape('a<b && c>d'), 'a<b && c>d');
|
|
217
|
-
assert.equal(escapeUrl('javascript:alert(1)'), '');
|
|
218
|
-
});
|
|
219
|
-
```
|
|
118
|
+
const response = await fetch('https://example.test/article');
|
|
119
|
+
if (response.body === null) throw new Error('Response has no body');
|
|
220
120
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
121
|
+
const transform = createTextTransform({ links: 'label-and-url' });
|
|
122
|
+
const readable = response.body
|
|
123
|
+
.pipeThrough(new TextDecoderStream())
|
|
124
|
+
.pipeThrough(transform);
|
|
125
|
+
const reader = readable.getReader();
|
|
226
126
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
Our comprehensive test suite evaluates sanitizers against:
|
|
234
|
-
|
|
235
|
-
- **84 attack vectors** from OWASP, PortSwigger, cure53, and regression research
|
|
236
|
-
- **Advanced polyglot attacks** that combine multiple bypass techniques
|
|
237
|
-
- **Encoding variations** (Unicode, HTML entities, URL encoding)
|
|
238
|
-
- **Context-breaking attacks** for different HTML contexts
|
|
239
|
-
- **Modern browser vectors** including HTML5 and SVG attacks
|
|
240
|
-
- **Template injection** patterns from popular frameworks
|
|
241
|
-
|
|
242
|
-
### Test Categories:
|
|
243
|
-
1. **Basic XSS** - Standard script injection attempts
|
|
244
|
-
2. **Event Handlers** - Various HTML event attributes
|
|
245
|
-
3. **Protocol Variations** - javascript:, vbscript:, data: URIs
|
|
246
|
-
4. **CSS Expressions** - Style-based code execution
|
|
247
|
-
5. **Template Injection** - Framework-specific patterns
|
|
248
|
-
6. **Polyglot Attacks** - Multi-context bypass attempts
|
|
249
|
-
7. **Encoding Bypasses** - Obfuscation techniques
|
|
250
|
-
8. **Modern Vectors** - HTML5, SVG, and browser-specific attacks
|
|
251
|
-
|
|
252
|
-
## 📊 Detailed Comparison
|
|
253
|
-
|
|
254
|
-
### Security Comparison by Attack Type
|
|
255
|
-
|
|
256
|
-
Superseded by the two-axis benchmark above. The per-category percentages that
|
|
257
|
-
used to sit here came from a scoring rule that counted "output is empty" as a
|
|
258
|
-
win, so it rewarded deletion rather than safety and marked correct competitor
|
|
259
|
-
behaviour as failure. Run `pnpm test:fair` for numbers that survive scrutiny.
|
|
260
|
-
|
|
261
|
-
### Bundle Size Comparison
|
|
262
|
-
|
|
263
|
-
| Library | Category | Target | Minified | Gzip | Direct runtime deps |
|
|
264
|
-
|---------|----------|--------|----------|------|---------------------|
|
|
265
|
-
| **Purifai** | strip-text | browser | **3.5 KB** | **1.6 KB** | **0** |
|
|
266
|
-
| striptags | strip-text | browser | 2.1 KB | 1.1 KB | 0 |
|
|
267
|
-
| DOMPurify | preserve-html | browser | 28.0 KB | 10.6 KB | 0 |
|
|
268
|
-
| sanitize-html | preserve-html | Node | 192.2 KB | 70.4 KB | 7 |
|
|
269
|
-
| xss | preserve-html | browser | 18.4 KB | 6.2 KB | 2 |
|
|
270
|
-
| rehype-sanitize | preserve-html | browser | 244.5 KB | 70.7 KB | 2 |
|
|
271
|
-
| escape-html | escape-html | browser | 1.2 KB | 0.7 KB | 0 |
|
|
272
|
-
| validator.escape | escape-html | browser | 0.4 KB | 0.2 KB | 0 |
|
|
273
|
-
| entities.escapeUTF8 | escape-html | browser | 0.7 KB | 0.4 KB | 0 |
|
|
274
|
-
| html-entities | escape-html | browser | 34.8 KB | 13.1 KB | 0 |
|
|
275
|
-
| he.escape | escape-html | browser | 85.7 KB | 30.2 KB | 0 |
|
|
276
|
-
|
|
277
|
-
Measured by `pnpm test:size` with esbuild 0.27.7: smallest supported ESM
|
|
278
|
-
import, bundled and minified for ES2020, then gzipped. The lockfile pins the
|
|
279
|
-
exact library versions. DOMPurify is measured against the native browser API;
|
|
280
|
-
sanitize-html is a Node bundle; the rehype row includes the parser, sanitizer,
|
|
281
|
-
and serializer pipeline. Direct dependency counts come from each named package's
|
|
282
|
-
manifest. Compare sizes within a category and target—the tools do different jobs.
|
|
283
|
-
|
|
284
|
-
## 🌟 Use Cases
|
|
285
|
-
|
|
286
|
-
### Web Applications
|
|
287
|
-
```typescript
|
|
288
|
-
// Sanitize user-generated content
|
|
289
|
-
app.post('/comments', (req, res) => {
|
|
290
|
-
const safeComment = Purifai.sanitize(req.body.comment);
|
|
291
|
-
// Store safeComment in database
|
|
292
|
-
});
|
|
293
|
-
```
|
|
127
|
+
for (;;) {
|
|
128
|
+
const { done, value } = await reader.read();
|
|
129
|
+
if (done) break;
|
|
130
|
+
consumeText(value);
|
|
131
|
+
}
|
|
294
132
|
|
|
295
|
-
|
|
296
|
-
```typescript
|
|
297
|
-
// Reduce rich text editor content to safe plain text.
|
|
298
|
-
// Note: Purifai strips ALL markup. If you need to KEEP <b>/<a href>,
|
|
299
|
-
// use DOMPurify or sanitize-html instead - that is a different job.
|
|
300
|
-
const cleanText = Purifai.sanitize(editorContent, {
|
|
301
|
-
maxLength: 50000
|
|
302
|
-
});
|
|
133
|
+
const report = await transform.result;
|
|
303
134
|
```
|
|
304
135
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const result = analyze(data.message);
|
|
310
|
-
// hadThreats/threatLevel are advisory telemetry, not an auth gate.
|
|
311
|
-
broadcast(result.content);
|
|
312
|
-
});
|
|
313
|
-
```
|
|
136
|
+
Stream conversion always throws on a breached limit. Output may already have
|
|
137
|
+
been enqueued when `readable` and `transform.result` reject, so discard partial
|
|
138
|
+
output unless your application has deliberately defined it as useful. Purifai
|
|
139
|
+
does not buffer the whole result to make an error transactional.
|
|
314
140
|
|
|
315
|
-
|
|
141
|
+
### `escapeHtmlText(text)`
|
|
316
142
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
import DOMPurify from 'dompurify';
|
|
321
|
-
const clean = DOMPurify.sanitize(dirty);
|
|
143
|
+
Encodes `&`, `<`, `>`, `"`, and `'` for an HTML text node. It is lossless and is
|
|
144
|
+
for plain text—including `toText` output—when the only available sink is
|
|
145
|
+
`innerHTML`.
|
|
322
146
|
|
|
323
|
-
|
|
324
|
-
import { sanitize } from 'purifai';
|
|
325
|
-
const clean = sanitize(dirty);
|
|
326
|
-
// Only migrate when dropping every tag is intended. Otherwise keep DOMPurify.
|
|
327
|
-
```
|
|
147
|
+
### `PurifaiLimitError`
|
|
328
148
|
|
|
329
|
-
|
|
330
|
-
```typescript
|
|
331
|
-
// Before
|
|
332
|
-
import sanitizeHtml from 'sanitize-html';
|
|
333
|
-
const clean = sanitizeHtml(dirty, options);
|
|
334
|
-
|
|
335
|
-
// After
|
|
336
|
-
import { sanitize } from 'purifai';
|
|
337
|
-
const clean = sanitize(dirty);
|
|
338
|
-
// Purifai removes all tags. If the migration needs to KEEP safe HTML,
|
|
339
|
-
// stay on sanitize-html - Purifai targets the strip-to-text case.
|
|
340
|
-
```
|
|
149
|
+
Extends `RangeError` and exposes `kind`, `limit`, and `observed`.
|
|
341
150
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// Before
|
|
345
|
-
import xss from 'xss';
|
|
346
|
-
const clean = xss(dirty);
|
|
151
|
+
```ts
|
|
152
|
+
import { PurifaiLimitError, toText } from 'purifai';
|
|
347
153
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
154
|
+
try {
|
|
155
|
+
toText(html, { limits: { input: 10_000 } });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error instanceof PurifaiLimitError) {
|
|
158
|
+
console.error(error.kind, error.limit, error.observed);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
352
161
|
```
|
|
353
162
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const clean = validator.escape(dirty);
|
|
163
|
+
## Options and defaults
|
|
164
|
+
|
|
165
|
+
Unknown keys and invalid values throw `TypeError`; Purifai does not silently
|
|
166
|
+
guess around configuration mistakes.
|
|
359
167
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
168
|
+
| Option | Type | Default | Meaning |
|
|
169
|
+
| --- | --- | --- | --- |
|
|
170
|
+
| `layout` | `'readable' \| 'compact'` | `'readable'` | Structural newlines/lists/tables, or normalized single-space text |
|
|
171
|
+
| `links` | `'label' \| 'label-and-url' \| 'drop'` | `'label'` | Keep label, append an accepted display URL, or drop the link body |
|
|
172
|
+
| `images` | `'alt' \| 'drop'` | `'alt'` | Emit decoded non-empty `alt` text, or omit images |
|
|
173
|
+
| `baseUrl` | `string \| URL` | none | Resolve relative display URLs against a credential-free HTTP(S) base |
|
|
174
|
+
| `limits.input` | non-negative safe integer | `1_000_000` | Maximum input UTF-16 code units consumed |
|
|
175
|
+
| `limits.output` | non-negative safe integer | `250_000` | Maximum output UTF-16 code units emitted |
|
|
176
|
+
| `limits.depth` | non-negative safe integer | `64` | Maximum live structural nesting |
|
|
177
|
+
| `limits.token` | non-negative safe integer | `65_536` | Maximum aggregate retained token/attribute code units |
|
|
178
|
+
| `overflow` | `'throw' \| 'truncate'` | `'throw'` | `convert` only; other APIs always throw |
|
|
364
179
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
import sanitize from 'node-sanitize';
|
|
369
|
-
const clean = sanitize(dirty);
|
|
180
|
+
All four limits are enforced while scanning, before unbounded caller-controlled
|
|
181
|
+
state can accumulate. Values measure JavaScript UTF-16 code units, not encoded
|
|
182
|
+
bytes.
|
|
370
183
|
|
|
371
|
-
|
|
372
|
-
import { sanitize } from 'purifai';
|
|
373
|
-
const clean = sanitize(dirty);
|
|
374
|
-
```
|
|
184
|
+
## Link policy
|
|
375
185
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
-
|
|
380
|
-
|
|
381
|
-
- **Context-specific encoders** instead of one output reused everywhere
|
|
382
|
-
- **No executable output observed** across the current 84-vector corpus
|
|
383
|
-
|
|
384
|
-
### Encoded Attack Detection
|
|
385
|
-
```typescript
|
|
386
|
-
// Markup variants are decoded before the strip-to-text scan:
|
|
387
|
-
'<script>alert(1)</script>' // Direct
|
|
388
|
-
'<script>alert(1)</script>' // HTML entities
|
|
389
|
-
'%3Cscript%3Ealert(1)%3C/script%3E' // URL encoded
|
|
390
|
-
'\\u003cscript\\u003ealert(1)\\u003c/script\\u003e' // Unicode
|
|
391
|
-
```
|
|
186
|
+
`label-and-url` emits a destination as display text, never as an active link. It
|
|
187
|
+
accepts absolute `http:`, `https:`, and `mailto:` URLs. Relative URLs require a
|
|
188
|
+
validated HTTP(S) `baseUrl`. Control characters, whitespace-split schemes,
|
|
189
|
+
protocol-relative inputs, leading backslashes, credentials, unsupported schemes,
|
|
190
|
+
and invalid URLs are omitted while their visible label remains.
|
|
392
191
|
|
|
393
|
-
|
|
192
|
+
The returned URL string is still only text. Do not move it into `href` without a
|
|
193
|
+
separate URL-policy decision at that sink.
|
|
394
194
|
|
|
395
|
-
|
|
396
|
-
2026-08-02 Node 26.3.0 run, 64× larger inputs took 67.55× and 59.88× longer;
|
|
397
|
-
normalized cost stayed at 1.06× and 0.94×. These local results support the
|
|
398
|
-
scanner's near-linear design but are not a universal runtime guarantee.
|
|
195
|
+
## Extraction fidelity
|
|
399
196
|
|
|
400
|
-
|
|
197
|
+
Purifai intentionally removes source and non-reader bodies including `script`,
|
|
198
|
+
`style`, `template`, `iframe`, `noscript`, `noembed`, `noframes`, `svg`, and
|
|
199
|
+
`math`. It preserves selected fallback/form text, decodes `textarea`, preserves
|
|
200
|
+
literal `xmp`, and treats `plaintext` as text through end of input.
|
|
401
201
|
|
|
402
|
-
|
|
202
|
+
This is a bounded extraction grammar, not browser tree construction. It does not
|
|
203
|
+
recreate CSS layout, browser `innerText`, complex `rowspan`/`colspan` tables,
|
|
204
|
+
SVG/MathML semantics, selector rules, custom formatters, or browser-equivalent
|
|
205
|
+
malformed-markup recovery. Simple rows and cells are represented with tabs and
|
|
206
|
+
line boundaries.
|
|
403
207
|
|
|
404
|
-
|
|
405
|
-
```bash
|
|
406
|
-
git clone https://github.com/moji2002/purifai.git
|
|
407
|
-
cd purifai
|
|
408
|
-
pnpm install
|
|
409
|
-
pnpm build
|
|
410
|
-
pnpm test
|
|
411
|
-
```
|
|
208
|
+
## Which tool should you choose?
|
|
412
209
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
210
|
+
| Need | Choice |
|
|
211
|
+
| --- | --- |
|
|
212
|
+
| Fixed-policy readable text, hostile-input bounds, and portable Web streaming | Choose Purifai |
|
|
213
|
+
| Selectors, custom formatters, advanced tables, wrapping, and broader formatting control | Choose `html-to-text` |
|
|
214
|
+
| The smallest flat tag-removal operation | Choose stable `striptags` |
|
|
215
|
+
| Preserve an allow-listed safe HTML fragment | Choose DOMPurify or `sanitize-html` |
|
|
417
216
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
competitor set and exact versions are pinned in `package.json` and
|
|
421
|
-
`pnpm-lock.yaml` so results are reproducible.
|
|
217
|
+
These tools are not interchangeable. In particular, DOMPurify and
|
|
218
|
+
`sanitize-html` are the right category when safe markup must survive.
|
|
422
219
|
|
|
423
|
-
##
|
|
220
|
+
## Benchmarks
|
|
424
221
|
|
|
425
|
-
|
|
222
|
+
The checked category benchmark pins `striptags@3.2.0` and
|
|
223
|
+
`html-to-text@10.0.0`. It measures exact readability/body-removal fixtures,
|
|
224
|
+
isolated warm median and p95 one-shot latency, and fresh-process peak RSS. The
|
|
225
|
+
throughput path gives every package the same materialized string; the memory path
|
|
226
|
+
lets Purifai consume lazy 16,384-code-unit chunks because streaming ingestion is
|
|
227
|
+
the product claim.
|
|
426
228
|
|
|
427
|
-
|
|
229
|
+
On the recorded Apple M1 / Node 24 run, Purifai passed all 11 category gates: all
|
|
230
|
+
readability and body-removal fixtures, lower hostile-input p95 than
|
|
231
|
+
`html-to-text` on four hostile corpora, and lower streaming peak RSS on all five
|
|
232
|
+
memory corpora. `striptags` remains faster on some flat-strip cases, which is not
|
|
233
|
+
Purifai's claim.
|
|
428
234
|
|
|
429
|
-
|
|
430
|
-
-
|
|
431
|
-
|
|
235
|
+
See the [complete methodology, raw-result link, and tables](docs/benchmarks/v3.md).
|
|
236
|
+
Reproduce it with `pnpm run bench`; re-check the saved gates with
|
|
237
|
+
`pnpm run bench:check`.
|
|
432
238
|
|
|
433
|
-
##
|
|
239
|
+
## Size and runtime matrix
|
|
434
240
|
|
|
435
|
-
|
|
436
|
-
-
|
|
437
|
-
|
|
241
|
+
The complete minified ESM runtime—including the full 2,231-name WHATWG entity
|
|
242
|
+
data—is gated at 25 KiB using deterministic `gzip -9`. The recorded artifact is
|
|
243
|
+
23,689 bytes. `pnpm run test:size` also checks the packed exports, zero runtime
|
|
244
|
+
dependencies, cold import time, and retained import heap.
|
|
438
245
|
|
|
439
|
-
|
|
246
|
+
The release matrix exercises the same packed ESM artifact:
|
|
440
247
|
|
|
441
|
-
|
|
248
|
+
| Runtime | Required release coverage |
|
|
249
|
+
| --- | --- |
|
|
250
|
+
| Node.js | 22, 24, and 26; ESM and CommonJS |
|
|
251
|
+
| Bun | ESM and CommonJS consumers |
|
|
252
|
+
| Deno | ESM consumer |
|
|
253
|
+
| Cloudflare Workers | real `workerd`, without Node compatibility |
|
|
254
|
+
| Browsers | Chromium, Firefox, and WebKit |
|
|
442
255
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
256
|
+
Browser qualification also reparses escaped output in a real DOM with a working
|
|
257
|
+
positive control. This validates the documented sinks; it is not a universal
|
|
258
|
+
claim about every output context.
|
|
259
|
+
|
|
260
|
+
## Migration and development
|
|
446
261
|
|
|
447
|
-
|
|
262
|
+
V3 is a clean break. See the [v3 migration guide](docs/migration-v3.md) for every
|
|
263
|
+
removed export and option. Contributor setup and the full verification commands
|
|
264
|
+
are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
MIT
|