purifai 2.0.2 β 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 -368
- package/benchmark/results/v3.json +2767 -0
- package/dist/index.cjs +14 -406
- package/dist/index.d.cts +2 -191
- package/dist/index.d.ts +2 -191
- package/dist/index.js +14 -374
- 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 -38
package/README.md
CHANGED
|
@@ -1,432 +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 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% | β |
|
|
45
|
-
|
|
46
|
-
84 attack vectors (OWASP, PortSwigger, cure53 corpora) Β· 15 benign documents.
|
|
47
|
-
|
|
48
|
-
\* Throughput is a single-process development snapshot captured on 2026-08-02
|
|
49
|
-
with Node 26.3.0 on Apple Silicon. It varies by hardware and runtime and should
|
|
50
|
-
only be compared within the same category. The command above is the source of
|
|
51
|
-
truth. HTML encoders safely display the original markup as text; unlike Purifai
|
|
52
|
-
and striptags, they do not turn markup-bearing input into clean reader text.
|
|
53
|
-
|
|
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.
|
|
61
|
-
|
|
62
|
-
## π Why Purifai?
|
|
63
|
-
|
|
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
|
|
69
|
-
- Contextual encoders for HTML body, attribute, and URL contexts
|
|
70
|
-
|
|
71
|
-
### β‘ High Performance
|
|
72
|
-
- **Fast processing** - optimized algorithms for high-throughput applications
|
|
73
|
-
- **Zero dependencies** - minimal bundle size and attack surface
|
|
74
|
-
- **TypeScript native** with full type definitions
|
|
75
|
-
- **Node.js and Browser** compatible
|
|
76
|
-
|
|
77
|
-
### π― Developer Friendly
|
|
78
|
-
- Simple API with intelligent defaults
|
|
79
|
-
- Detailed threat analysis and reporting
|
|
80
|
-
- Batch processing support
|
|
81
|
-
- Comprehensive documentation
|
|
82
|
-
|
|
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:
|
|
90
|
-
|
|
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)>
|
|
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
|
|
99
25
|
```
|
|
100
26
|
|
|
101
|
-
|
|
102
|
-
|
|
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>`
|
|
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.
|
|
108
29
|
|
|
109
|
-
|
|
30
|
+
The output is a JavaScript string, not safe HTML. Use one of these supported
|
|
31
|
+
sinks:
|
|
110
32
|
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
pnpm add purifai
|
|
33
|
+
```ts
|
|
34
|
+
import { escapeHtmlText, toText } from 'purifai';
|
|
35
|
+
|
|
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
|
-
|
|
44
|
+
## Why it exists
|
|
122
45
|
|
|
123
|
-
|
|
124
|
-
import { Purifai } from 'purifai';
|
|
46
|
+
Purifai targets one narrow intersection:
|
|
125
47
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
129
53
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
maxLength: 10000,
|
|
133
|
-
aggressiveMode: true
|
|
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
|
-
|
|
57
|
+
## Install
|
|
138
58
|
|
|
139
|
-
```
|
|
140
|
-
|
|
59
|
+
```sh
|
|
60
|
+
npm install purifai
|
|
61
|
+
```
|
|
141
62
|
|
|
142
|
-
|
|
63
|
+
Purifai v3 requires Node.js 22 or newer when used in Node.
|
|
143
64
|
|
|
144
|
-
|
|
145
|
-
console.log(result.hadThreats); // true
|
|
146
|
-
console.log(result.threatLevel); // "critical"
|
|
147
|
-
console.log(result.processingTime); // 0.023 (ms)
|
|
148
|
-
```
|
|
65
|
+
## API
|
|
149
66
|
|
|
150
|
-
###
|
|
67
|
+
### `toText(html, options?)`
|
|
151
68
|
|
|
152
|
-
|
|
153
|
-
|
|
69
|
+
Converts one string and returns readable text. A breached limit throws a
|
|
70
|
+
`PurifaiLimitError`.
|
|
154
71
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
'<img src=x onerror=alert(1)>World',
|
|
158
|
-
'Safe content'
|
|
159
|
-
];
|
|
72
|
+
```ts
|
|
73
|
+
import { toText } from 'purifai';
|
|
160
74
|
|
|
161
|
-
const
|
|
162
|
-
|
|
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
|
+
});
|
|
163
82
|
```
|
|
164
83
|
|
|
165
|
-
###
|
|
84
|
+
### `convert(html, options?)`
|
|
166
85
|
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
169
88
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
89
|
+
```ts
|
|
90
|
+
import { convert } from 'purifai';
|
|
91
|
+
|
|
92
|
+
const result = convert(largeHtml, {
|
|
93
|
+
limits: { output: 20_000 },
|
|
94
|
+
overflow: 'truncate',
|
|
95
|
+
});
|
|
96
|
+
|
|
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 }
|
|
174
103
|
```
|
|
175
104
|
|
|
176
|
-
|
|
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.
|
|
177
108
|
|
|
178
|
-
`
|
|
179
|
-
**plain text**, escaping is the better tool β it is lossless, and no guessing is
|
|
180
|
-
involved. These follow OWASP's context-specific output encoding guidance.
|
|
109
|
+
### `createTextTransform(options?)`
|
|
181
110
|
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
184
114
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
// "if (a<b && c>d) return;"
|
|
115
|
+
```ts
|
|
116
|
+
import { createTextTransform } from 'purifai';
|
|
188
117
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
`<div title="${escapeAttribute(userInput)}">`;
|
|
118
|
+
const response = await fetch('https://example.test/article');
|
|
119
|
+
if (response.body === null) throw new Error('Response has no body');
|
|
192
120
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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();
|
|
198
126
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
## βοΈ Configuration Options
|
|
205
|
-
|
|
206
|
-
```typescript
|
|
207
|
-
interface PurifaiOptions {
|
|
208
|
-
/** Maximum input length (default: 1MB) */
|
|
209
|
-
maxLength?: number;
|
|
210
|
-
|
|
211
|
-
/** Custom allowed protocols (default: ['http', 'https', 'mailto']) */
|
|
212
|
-
allowedProtocols?: string[];
|
|
213
|
-
|
|
214
|
-
/** Enable aggressive mode for maximum security (default: true) */
|
|
215
|
-
aggressiveMode?: boolean;
|
|
127
|
+
for (;;) {
|
|
128
|
+
const { done, value } = await reader.read();
|
|
129
|
+
if (done) break;
|
|
130
|
+
consumeText(value);
|
|
216
131
|
}
|
|
217
|
-
```
|
|
218
132
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
Our comprehensive test suite evaluates sanitizers against:
|
|
222
|
-
|
|
223
|
-
- **64 sophisticated attack vectors** from OWASP, PortSwigger, and security research
|
|
224
|
-
- **Advanced polyglot attacks** that combine multiple bypass techniques
|
|
225
|
-
- **Encoding variations** (Unicode, HTML entities, URL encoding)
|
|
226
|
-
- **Context-breaking attacks** for different HTML contexts
|
|
227
|
-
- **Modern browser vectors** including HTML5 and SVG attacks
|
|
228
|
-
- **Template injection** patterns from popular frameworks
|
|
229
|
-
|
|
230
|
-
### Test Categories:
|
|
231
|
-
1. **Basic XSS** - Standard script injection attempts
|
|
232
|
-
2. **Event Handlers** - Various HTML event attributes
|
|
233
|
-
3. **Protocol Variations** - javascript:, vbscript:, data: URIs
|
|
234
|
-
4. **CSS Expressions** - Style-based code execution
|
|
235
|
-
5. **Template Injection** - Framework-specific patterns
|
|
236
|
-
6. **Polyglot Attacks** - Multi-context bypass attempts
|
|
237
|
-
7. **Encoding Bypasses** - Obfuscation techniques
|
|
238
|
-
8. **Modern Vectors** - HTML5, SVG, and browser-specific attacks
|
|
239
|
-
|
|
240
|
-
## π Detailed Comparison
|
|
241
|
-
|
|
242
|
-
### Security Comparison by Attack Type
|
|
243
|
-
|
|
244
|
-
Superseded by the two-axis benchmark above. The per-category percentages that
|
|
245
|
-
used to sit here came from a scoring rule that counted "output is empty" as a
|
|
246
|
-
win, so it rewarded deletion rather than safety and marked correct competitor
|
|
247
|
-
behaviour as failure. Run `pnpm test:fair` for numbers that survive scrutiny.
|
|
248
|
-
|
|
249
|
-
### Bundle Size Comparison
|
|
250
|
-
|
|
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 |
|
|
259
|
-
|
|
260
|
-
## π Use Cases
|
|
261
|
-
|
|
262
|
-
### Web Applications
|
|
263
|
-
```typescript
|
|
264
|
-
// Sanitize user-generated content
|
|
265
|
-
app.post('/comments', (req, res) => {
|
|
266
|
-
const safeComment = Purifai.sanitize(req.body.comment);
|
|
267
|
-
// Store safeComment in database
|
|
268
|
-
});
|
|
133
|
+
const report = await transform.result;
|
|
269
134
|
```
|
|
270
135
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// use DOMPurify or sanitize-html instead - that is a different job.
|
|
276
|
-
const cleanText = Purifai.sanitize(editorContent, {
|
|
277
|
-
maxLength: 50000
|
|
278
|
-
});
|
|
279
|
-
```
|
|
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.
|
|
280
140
|
|
|
281
|
-
###
|
|
282
|
-
```typescript
|
|
283
|
-
// Sanitize all incoming string data
|
|
284
|
-
const sanitizedPayload = sanitizeBatch(Object.values(request.body));
|
|
285
|
-
```
|
|
141
|
+
### `escapeHtmlText(text)`
|
|
286
142
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
socket.on('message', (data) => {
|
|
291
|
-
const result = analyze(data.message);
|
|
292
|
-
if (result.threatLevel === 'critical') {
|
|
293
|
-
// Block and log the attempt
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
broadcast(result.content);
|
|
297
|
-
});
|
|
298
|
-
```
|
|
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`.
|
|
299
146
|
|
|
300
|
-
|
|
147
|
+
### `PurifaiLimitError`
|
|
301
148
|
|
|
302
|
-
|
|
303
|
-
```typescript
|
|
304
|
-
// Before
|
|
305
|
-
import DOMPurify from 'dompurify';
|
|
306
|
-
const clean = DOMPurify.sanitize(dirty);
|
|
149
|
+
Extends `RangeError` and exposes `kind`, `limit`, and `observed`.
|
|
307
150
|
|
|
308
|
-
|
|
309
|
-
import {
|
|
310
|
-
const clean = sanitize(dirty);
|
|
311
|
-
```
|
|
151
|
+
```ts
|
|
152
|
+
import { PurifaiLimitError, toText } from 'purifai';
|
|
312
153
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
import { sanitize } from 'purifai';
|
|
321
|
-
const clean = sanitize(dirty);
|
|
322
|
-
// Purifai removes all tags. If the migration needs to KEEP safe HTML,
|
|
323
|
-
// stay on sanitize-html - Purifai targets the strip-to-text case.
|
|
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
|
+
}
|
|
324
161
|
```
|
|
325
162
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const clean = xss(dirty);
|
|
331
|
-
|
|
332
|
-
// After
|
|
333
|
-
import { sanitize } from 'purifai';
|
|
334
|
-
const clean = sanitize(dirty);
|
|
335
|
-
```
|
|
163
|
+
## Options and defaults
|
|
164
|
+
|
|
165
|
+
Unknown keys and invalid values throw `TypeError`; Purifai does not silently
|
|
166
|
+
guess around configuration mistakes.
|
|
336
167
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 |
|
|
342
179
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
```
|
|
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.
|
|
347
183
|
|
|
348
|
-
|
|
349
|
-
```typescript
|
|
350
|
-
// Before
|
|
351
|
-
import sanitize from 'node-sanitize';
|
|
352
|
-
const clean = sanitize(dirty);
|
|
184
|
+
## Link policy
|
|
353
185
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
358
191
|
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
366
|
-
|
|
367
|
-
### Encoded Attack Detection
|
|
368
|
-
```typescript
|
|
369
|
-
// All these variants are detected and blocked:
|
|
370
|
-
'<script>alert(1)</script>' // Direct
|
|
371
|
-
'<script>alert(1)</script>' // HTML entities
|
|
372
|
-
'%3Cscript%3Ealert(1)%3C/script%3E' // URL encoded
|
|
373
|
-
'\\u003cscript\\u003ealert(1)\\u003c/script\\u003e' // Unicode
|
|
374
|
-
```
|
|
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.
|
|
375
194
|
|
|
376
|
-
##
|
|
195
|
+
## Extraction fidelity
|
|
377
196
|
|
|
378
|
-
Purifai
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
- **Minimal CPU** usage through intelligent algorithms
|
|
383
|
-
- **Scalable** performance across different input sizes and complexity
|
|
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.
|
|
384
201
|
|
|
385
|
-
|
|
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.
|
|
386
207
|
|
|
387
|
-
|
|
208
|
+
## Which tool should you choose?
|
|
388
209
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
pnpm test
|
|
396
|
-
```
|
|
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` |
|
|
397
216
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
pnpm benchmark
|
|
401
|
-
```
|
|
217
|
+
These tools are not interchangeable. In particular, DOMPurify and
|
|
218
|
+
`sanitize-html` are the right category when safe markup must survive.
|
|
402
219
|
|
|
403
|
-
|
|
404
|
-
then measures Purifai's throughput, critical-attack checks, and bundle size. The
|
|
405
|
-
competitor set and exact versions are pinned in `package.json` and
|
|
406
|
-
`pnpm-lock.yaml` so results are reproducible.
|
|
220
|
+
## Benchmarks
|
|
407
221
|
|
|
408
|
-
|
|
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.
|
|
409
228
|
|
|
410
|
-
|
|
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.
|
|
411
234
|
|
|
412
|
-
|
|
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`.
|
|
413
238
|
|
|
414
|
-
|
|
415
|
-
- Inspiration from existing sanitization libraries
|
|
416
|
-
- Comprehensive testing methodologies from security experts
|
|
239
|
+
## Size and runtime matrix
|
|
417
240
|
|
|
418
|
-
|
|
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.
|
|
419
245
|
|
|
420
|
-
|
|
421
|
-
- [PortSwigger XSS Labs](https://portswigger.net/web-security/cross-site-scripting)
|
|
422
|
-
- [MDN Web Security](https://developer.mozilla.org/en-US/docs/Web/Security)
|
|
246
|
+
The release matrix exercises the same packed ESM artifact:
|
|
423
247
|
|
|
424
|
-
|
|
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 |
|
|
425
255
|
|
|
426
|
-
|
|
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.
|
|
427
259
|
|
|
428
|
-
|
|
429
|
-
npm install purifai
|
|
430
|
-
```
|
|
260
|
+
## Migration and development
|
|
431
261
|
|
|
432
|
-
|
|
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
|