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 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, which makes it immune to mutation XSS by
13
- construction β€” nothing survives for a parser to mutate on re-parse.
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.1 KB gzipped footprint that runs in Node, browsers,
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
- ## πŸ† Benchmark Results
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 blocked only if *neither* parse yields a script node, an `on*`
27
- handler, or a dangerous-protocol URL. The suite covers the major Node-compatible
28
- sanitizers, tag strippers, and HTML encoders with materially different APIs.
29
-
30
- | Library | Category | Security | Text kept | Markup kept | Ops/sec* |
31
- |---------|----------|----------|-----------|-------------|----------|
32
- | **Purifai** | strip-text | **100%** | **100%** | 0% *(by design)* | **341,705** |
33
- | striptags | strip-text | 100% | 100% | 0% | 674,214 |
34
- | DOMPurify | preserve-html | 100% | 100% | 100% | 1,809 |
35
- | sanitize-html | preserve-html | 100% | 100% | 100% | 85,340 |
36
- | xss | preserve-html | 100% | 100% | 100% | 240,911 |
37
- | rehype-sanitize | preserve-html | 100% | 100% | 100% | 23,738 |
38
- | escape-html | escape-html | 100% | 60% | 0% | 2,374,124 |
39
- | validator.escape | escape-html | 100% | 60% | 0% | 713,563 |
40
- | entities.escapeUTF8 | escape-html | 100% | 60% | 0% | 1,148,270 |
41
- | html-entities | escape-html | 100% | 60% | 0% | 890,538 |
42
- | he.escape | escape-html | 100% | 60% | 0% | 1,054,481 |
43
- | `() => ''` *(calibration)* | β€” | 100% | 0% | 0% | β€” |
44
- | `v => v` *(calibration)* | β€” | 38.1% | 100% | 100% | β€” |
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
- \* Throughput is a single-process development snapshot captured on 2026-08-02
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
- > πŸ“Š **Read this honestly.** On security there is no headroom left β€” every
55
- > maintained sanitizer above blocks everything thrown at it. The calibration rows
56
- > are the whole point: a function returning `''` also scores 100% security, which
57
- > is why a security number means nothing without a fidelity axis beside it.
58
- > Purifai's 0% markup retention is its design, not a defect β€” but it does mean
59
- > Purifai is **not** "more secure than DOMPurify". It is smaller, dependency-free,
60
- > and needs no DOM.
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
- - Blocks every vector in the 84-payload corpus, verified by real DOM re-parse
66
- - Handles **Unicode**, **HTML entity**, and **URL encoding** bypasses
67
- - Prevents **template injection** and **CSS expression** attacks
68
- - Immune to **mutation XSS** by construction: it emits no markup to mutate
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
- ## πŸ”₯ Attack Vectors Blocked
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
- // Universal XSS Polyglot
92
- jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()///>\\x3e
93
-
94
- // Ultimate XSS Polyglot
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: `&#60;script&#62;alert(1)&#60;/script&#62;`
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
- // Log security incident
172
- console.warn('Potential XSS attack detected');
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: `a<b && c>d` is a valid HTML start tag per the parsing spec, so
200
- `sanitize()` correctly drops it while `escape()` preserves it verbatim. Only the
201
- caller knows whether a string is markup or text β€” so the API asks rather than
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
- /** Custom allowed protocols (default: ['http', 'https', 'mailto']) */
193
+ /** Subset of built-in safe protocols: http, https, mailto */
212
194
  allowedProtocols?: string[];
213
195
 
214
- /** Enable aggressive mode for maximum security (default: true) */
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&lt;b &amp;&amp; c&gt;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
- - **64 sophisticated attack vectors** from OWASP, PortSwigger, and security research
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 | Bundle Size | Dependencies | TypeScript |
252
- |---------|-------------|--------------|-------------|
253
- | **Purifai** | **13.6KB / 4.1KB gzip** | **0** | **βœ… Native** |
254
- | validator.js | ~15KB | 0 | βœ… Available |
255
- | xss | ~25KB | 3 | ❌ None |
256
- | node-sanitize | ~32KB | 5 | ❌ None |
257
- | DOMPurify | ~45KB | 0 | βœ… Available |
258
- | sanitize-html | ~200KB+ | 15+ | βœ… Available |
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
- if (result.threatLevel === 'critical') {
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 { sanitize } from 'purifai';
345
- const clean = sanitize(dirty);
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
- ### Advanced Protection Techniques
362
- - **Multi-layer sanitization** with fallback mechanisms
363
- - **Context-aware parsing** to prevent bypass attempts
364
- - **Aggressive mode** for maximum security applications
365
- - **No executable output** across the current 84-vector benchmark corpus
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
- // All these variants are detected and blocked:
386
+ // Markup variants are decoded before the strip-to-text scan:
370
387
  '<script>alert(1)</script>' // Direct
371
388
  '&#60;script&#62;alert(1)&#60;/script&#62;' // 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
- Purifai is optimized for:
379
- - **High-throughput** applications with efficient processing
380
- - **Low memory** footprint with optimized regex patterns
381
- - **Fast startup** with zero dependencies
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