purifai 2.0.2 β 2.0.3
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 +119 -104
- package/dist/index.cjs +206 -137
- package/dist/index.d.cts +18 -20
- package/dist/index.d.ts +18 -20
- package/dist/index.js +206 -137
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -9,63 +9,65 @@
|
|
|
9
9
|
|
|
10
10
|
Purifai is a lightweight, zero-dependency sanitizer for the **strip-to-text** case:
|
|
11
11
|
untrusted content that must be displayed as text, never as markup. It removes all
|
|
12
|
-
HTML rather than allow-listing safe tags
|
|
13
|
-
|
|
12
|
+
HTML rather than allow-listing safe tags. The result contains no retained markup
|
|
13
|
+
for an HTML parser to mutate on re-parse.
|
|
14
14
|
|
|
15
15
|
**Reach for Purifai when** you want untrusted input rendered as plain text, with no
|
|
16
|
-
dependencies, no DOM, and a ~4.
|
|
16
|
+
dependencies, no DOM, and a ~4.5 KB gzipped package build that runs in Node, browsers,
|
|
17
17
|
and edge runtimes alike.
|
|
18
18
|
|
|
19
19
|
**Reach for DOMPurify or sanitize-html when** you need to *keep* safe formatting
|
|
20
20
|
such as `<b>` and `<a href>`. That is a harder problem and they solve it well.
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Benchmark snapshot
|
|
23
23
|
|
|
24
24
|
Produced by `pnpm benchmark`. Each tool's output is inserted into a real DOM
|
|
25
25
|
(jsdom), then serialized and re-parsed β the round trip where mutation XSS lives.
|
|
26
|
-
A vector counts as
|
|
27
|
-
handler, or a dangerous-protocol URL.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
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
45
|
|
|
46
46
|
84 attack vectors (OWASP, PortSwigger, cure53 corpora) Β· 15 benign documents.
|
|
47
47
|
|
|
48
|
-
\*
|
|
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
|
|
49
51
|
with Node 26.3.0 on Apple Silicon. It varies by hardware and runtime and should
|
|
50
52
|
only be compared within the same category. The command above is the source of
|
|
51
53
|
truth. HTML encoders safely display the original markup as text; unlike Purifai
|
|
52
54
|
and striptags, they do not turn markup-bearing input into clean reader text.
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
61
63
|
|
|
62
64
|
## π Why Purifai?
|
|
63
65
|
|
|
64
|
-
### β
Security
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
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
|
|
69
71
|
- Contextual encoders for HTML body, attribute, and URL contexts
|
|
70
72
|
|
|
71
73
|
### β‘ High Performance
|
|
@@ -80,31 +82,12 @@ and striptags, they do not turn markup-bearing input into clean reader text.
|
|
|
80
82
|
- Batch processing support
|
|
81
83
|
- Comprehensive documentation
|
|
82
84
|
|
|
83
|
-
##
|
|
84
|
-
|
|
85
|
-
Purifai blocks **all 84 vectors** in the corpus (64 classic + 20 modern mXSS/namespace), including:
|
|
86
|
-
|
|
87
|
-
### Critical Polyglot Attacks
|
|
88
|
-
```javascript
|
|
89
|
-
// β
Purifai reduces each of these to empty output:
|
|
85
|
+
## Test coverage
|
|
90
86
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
javascript:/*--></title></style></textarea></script></xmp><svg/onload='+/"/+/onmouseover=1/+/[*/[]/+alert(1)//'>'
|
|
96
|
-
|
|
97
|
-
// Namespace Confusion Attack
|
|
98
|
-
<form><math><mtext></form><form><mglyph><style></math><img src onerror=alert(1)>
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
### Standard XSS Vectors
|
|
102
|
-
- Script injection: `<script>alert("xss")</script>`
|
|
103
|
-
- Event handlers: `<img src=x onerror=alert(1)>`
|
|
104
|
-
- Protocol injection: `javascript:alert(1)`
|
|
105
|
-
- CSS expressions: `<div style="expression(alert(1))">`
|
|
106
|
-
- Template injection: `{{constructor.constructor("alert(1)")()}}`
|
|
107
|
-
- Encoding bypasses: `<script>alert(1)</script>`
|
|
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.
|
|
108
91
|
|
|
109
92
|
## π οΈ Installation
|
|
110
93
|
|
|
@@ -129,8 +112,7 @@ console.log(clean); // "Hello World"
|
|
|
129
112
|
|
|
130
113
|
// With options
|
|
131
114
|
const safe = Purifai.sanitize(userInput, {
|
|
132
|
-
maxLength: 10000
|
|
133
|
-
aggressiveMode: true
|
|
115
|
+
maxLength: 10000
|
|
134
116
|
});
|
|
135
117
|
```
|
|
136
118
|
|
|
@@ -168,8 +150,9 @@ const cleanData = sanitizeBatch(userInputs);
|
|
|
168
150
|
import { isDangerous } from 'purifai';
|
|
169
151
|
|
|
170
152
|
if (isDangerous(userInput)) {
|
|
171
|
-
//
|
|
172
|
-
|
|
153
|
+
// Optional telemetry only. Do not use this advisory signal as an
|
|
154
|
+
// authorization, authentication, or request-blocking decision.
|
|
155
|
+
console.warn('Potentially dangerous markup observed');
|
|
173
156
|
}
|
|
174
157
|
```
|
|
175
158
|
|
|
@@ -196,10 +179,9 @@ escapeUrl('javascript:alert(1)'); // ""
|
|
|
196
179
|
escapeUrl('java\tscript:alert(1)'); // "" (whitespace-split protocols too)
|
|
197
180
|
```
|
|
198
181
|
|
|
199
|
-
Why both exist: `
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
guesses.
|
|
182
|
+
Why both exist: `sanitize()` uses a conservative HTML-like scanner and returns
|
|
183
|
+
clean reader text, while `escape()` is lossless for text that must be displayed
|
|
184
|
+
exactly. Only the caller knows the destination context.
|
|
203
185
|
|
|
204
186
|
## βοΈ Configuration Options
|
|
205
187
|
|
|
@@ -208,19 +190,49 @@ interface PurifaiOptions {
|
|
|
208
190
|
/** Maximum input length (default: 1MB) */
|
|
209
191
|
maxLength?: number;
|
|
210
192
|
|
|
211
|
-
/**
|
|
193
|
+
/** Subset of built-in safe protocols: http, https, mailto */
|
|
212
194
|
allowedProtocols?: string[];
|
|
213
195
|
|
|
214
|
-
/**
|
|
196
|
+
/** @deprecated Retained for compatibility; strip-to-text is always used. */
|
|
215
197
|
aggressiveMode?: boolean;
|
|
216
198
|
}
|
|
217
199
|
```
|
|
218
200
|
|
|
201
|
+
`allowedProtocols` can narrow the built-in set but cannot add executable
|
|
202
|
+
schemes. Protocol-relative URLs are rejected.
|
|
203
|
+
|
|
204
|
+
## Test Purifai in your project
|
|
205
|
+
|
|
206
|
+
The example below imports only the public package API and runs with Node's
|
|
207
|
+
built-in test runner:
|
|
208
|
+
|
|
209
|
+
```javascript
|
|
210
|
+
import test from 'node:test';
|
|
211
|
+
import assert from 'node:assert/strict';
|
|
212
|
+
import { sanitize, escape, escapeUrl } from 'purifai';
|
|
213
|
+
|
|
214
|
+
test('renders untrusted markup as plain text', () => {
|
|
215
|
+
assert.equal(sanitize('<script>bad()</script><b>Hello</b>'), 'Hello');
|
|
216
|
+
assert.equal(escape('a<b && c>d'), 'a<b && c>d');
|
|
217
|
+
assert.equal(escapeUrl('javascript:alert(1)'), '');
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Save it as `purifai.test.mjs`, then run:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
node --test purifai.test.mjs
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
This is a regression example, not proof that an application is secure. Keep
|
|
228
|
+
authorization separate and use the encoder for the actual output context. The
|
|
229
|
+
repository-owned copy runs with `pnpm test:example`.
|
|
230
|
+
|
|
219
231
|
## π§ͺ Testing Methodology
|
|
220
232
|
|
|
221
233
|
Our comprehensive test suite evaluates sanitizers against:
|
|
222
234
|
|
|
223
|
-
- **
|
|
235
|
+
- **84 attack vectors** from OWASP, PortSwigger, cure53, and regression research
|
|
224
236
|
- **Advanced polyglot attacks** that combine multiple bypass techniques
|
|
225
237
|
- **Encoding variations** (Unicode, HTML entities, URL encoding)
|
|
226
238
|
- **Context-breaking attacks** for different HTML contexts
|
|
@@ -248,14 +260,26 @@ behaviour as failure. Run `pnpm test:fair` for numbers that survive scrutiny.
|
|
|
248
260
|
|
|
249
261
|
### Bundle Size Comparison
|
|
250
262
|
|
|
251
|
-
| Library |
|
|
252
|
-
|
|
253
|
-
| **Purifai** |
|
|
254
|
-
|
|
|
255
|
-
|
|
|
256
|
-
|
|
|
257
|
-
|
|
|
258
|
-
| sanitize-html |
|
|
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.
|
|
259
283
|
|
|
260
284
|
## π Use Cases
|
|
261
285
|
|
|
@@ -278,21 +302,12 @@ const cleanText = Purifai.sanitize(editorContent, {
|
|
|
278
302
|
});
|
|
279
303
|
```
|
|
280
304
|
|
|
281
|
-
### API Gateways
|
|
282
|
-
```typescript
|
|
283
|
-
// Sanitize all incoming string data
|
|
284
|
-
const sanitizedPayload = sanitizeBatch(Object.values(request.body));
|
|
285
|
-
```
|
|
286
|
-
|
|
287
305
|
### Real-time Chat
|
|
288
306
|
```typescript
|
|
289
307
|
// Clean messages before broadcasting
|
|
290
308
|
socket.on('message', (data) => {
|
|
291
309
|
const result = analyze(data.message);
|
|
292
|
-
|
|
293
|
-
// Block and log the attempt
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
310
|
+
// hadThreats/threatLevel are advisory telemetry, not an auth gate.
|
|
296
311
|
broadcast(result.content);
|
|
297
312
|
});
|
|
298
313
|
```
|
|
@@ -308,6 +323,7 @@ const clean = DOMPurify.sanitize(dirty);
|
|
|
308
323
|
// After
|
|
309
324
|
import { sanitize } from 'purifai';
|
|
310
325
|
const clean = sanitize(dirty);
|
|
326
|
+
// Only migrate when dropping every tag is intended. Otherwise keep DOMPurify.
|
|
311
327
|
```
|
|
312
328
|
|
|
313
329
|
### From sanitize-html
|
|
@@ -332,6 +348,7 @@ const clean = xss(dirty);
|
|
|
332
348
|
// After
|
|
333
349
|
import { sanitize } from 'purifai';
|
|
334
350
|
const clean = sanitize(dirty);
|
|
351
|
+
// Only migrate when dropping every tag is intended. Otherwise keep xss.
|
|
335
352
|
```
|
|
336
353
|
|
|
337
354
|
### From validator.js
|
|
@@ -340,9 +357,9 @@ const clean = sanitize(dirty);
|
|
|
340
357
|
import validator from 'validator';
|
|
341
358
|
const clean = validator.escape(dirty);
|
|
342
359
|
|
|
343
|
-
// After
|
|
344
|
-
import {
|
|
345
|
-
const clean =
|
|
360
|
+
// After: preserve the original encode-as-text behavior
|
|
361
|
+
import { escape } from 'purifai';
|
|
362
|
+
const clean = escape(dirty);
|
|
346
363
|
```
|
|
347
364
|
|
|
348
365
|
### From node-sanitize
|
|
@@ -358,15 +375,15 @@ const clean = sanitize(dirty);
|
|
|
358
375
|
|
|
359
376
|
## π Security Features
|
|
360
377
|
|
|
361
|
-
###
|
|
362
|
-
- **
|
|
363
|
-
- **
|
|
364
|
-
- **
|
|
365
|
-
- **No executable output** across the current 84-vector
|
|
378
|
+
### Defensive design
|
|
379
|
+
- **Forward-only scanning** with bounded adversarial scaling checks
|
|
380
|
+
- **Fail-closed raw-text removal** for unclosed scriptable containers
|
|
381
|
+
- **Context-specific encoders** instead of one output reused everywhere
|
|
382
|
+
- **No executable output observed** across the current 84-vector corpus
|
|
366
383
|
|
|
367
384
|
### Encoded Attack Detection
|
|
368
385
|
```typescript
|
|
369
|
-
//
|
|
386
|
+
// Markup variants are decoded before the strip-to-text scan:
|
|
370
387
|
'<script>alert(1)</script>' // Direct
|
|
371
388
|
'<script>alert(1)</script>' // HTML entities
|
|
372
389
|
'%3Cscript%3Ealert(1)%3C/script%3E' // URL encoded
|
|
@@ -375,12 +392,10 @@ const clean = sanitize(dirty);
|
|
|
375
392
|
|
|
376
393
|
## π Performance Optimization
|
|
377
394
|
|
|
378
|
-
|
|
379
|
-
-
|
|
380
|
-
|
|
381
|
-
-
|
|
382
|
-
- **Minimal CPU** usage through intelligent algorithms
|
|
383
|
-
- **Scalable** performance across different input sizes and complexity
|
|
395
|
+
`pnpm test:perf` measures two malformed-tag shapes from 2β128 KiB. In the
|
|
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.
|
|
384
399
|
|
|
385
400
|
## π€ Contributing
|
|
386
401
|
|