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/dist/index.js
CHANGED
|
@@ -1,173 +1,239 @@
|
|
|
1
1
|
// index.ts
|
|
2
|
-
var DANGEROUS_TAGS_WITH_CONTENT = /<(script|style|iframe|frame|object|embed|applet|meta|link|form|svg|math|base)(?:\s[^>]*)?>[\s\S]*?<\/\1>|<(script|style|iframe|frame|object|embed|applet|meta|link|form|svg|math|base)(?:\s[^>]*)?\/?>|<\/(script|style|iframe|frame|object|embed|applet|meta|link|form|svg|math|base)>/gi;
|
|
3
|
-
var EVENT_HANDLERS_ENHANCED = /\s*o\s*n\s*[a-z]+\s*=\s*["']?[^"'>]+["']?/gi;
|
|
4
|
-
var DANGEROUS_PROTOCOLS_ENHANCED = /(?:href|src|action|formaction|data|background|poster|code|cite|longdesc|usemap|itemtype|ping|manifest|archive|classid|codebase|datasrc|dynsrc|lowsrc|srcset)\s*=\s*["']?\s*(?:j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:|javascript\s*:|vbscript\s*:|data:text\/html|data:image\/svg\+xml(?:[^"'>]*script)?|filesystem:|chrome-extension:|blob:|about:|res:|ie:|ms-its:|mk:|mhtml:|file:|jar:|hcp:|ms-help:|disk:|vnd\.ms-|shell:|lynxcgi:|lynxexec:|news:|nntp:|telnet:|gopher:|wais:|prospero:|webcal:|ldap:|ldaps:|ftp:|ftps:|sftp:|ssh:|ircs?:|mailto:|xmpp:|sms:|smsto:|mms:|mmsto:|tel:|fax:|modem:|payto:|bitcoin:|ethereum:|magnet:)[^"'\s>]*/gi;
|
|
5
|
-
var DANGEROUS_ATTRIBUTES = /(?:expression|@import|javascript:|vbscript:|livescript:|mocha:|behavior:|constructor)\s*\(/gi;
|
|
6
|
-
var TEMPLATE_INJECTION = /\{\{[\s\S]*?\}\}|<%[\s\S]*?%>|<\?[\s\S]*?\?>|\${[\s\S]*?}|#\{[\s\S]*?}/g;
|
|
7
|
-
var DANGEROUS_FUNCTIONS = /\b(?:alert|eval|expression|Function|constructor|prototype|__proto__|document\.write|document\.writeln|window\.location|document\.location|setTimeout|setInterval|setImmediate|execScript|msSetImmediate|range\.createContextualFragment|range\.insertNode|insertAdjacentHTML|outerHTML)\s*\(/gi;
|
|
8
2
|
var NULL_CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g;
|
|
9
|
-
var
|
|
10
|
-
var
|
|
11
|
-
var
|
|
12
|
-
var
|
|
13
|
-
var
|
|
14
|
-
var
|
|
15
|
-
var
|
|
3
|
+
var HAS_CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/;
|
|
4
|
+
var ENCODED_SYNTAX = /%3[ce]|\\u[0-9a-f]{4}|\\x[0-9a-f]{2}|&#(?:\d+|x[0-9a-f]+);/i;
|
|
5
|
+
var DANGEROUS_CONTAINER_PATTERN = /<\s*\/?\s*(?:script|style|iframe|object|applet|svg|math|form|template|noscript|noframes|xmp|plaintext|head|title|textarea|frameset)\b/i;
|
|
6
|
+
var EVENT_HANDLER_PATTERN = /\bo\s*n\s*(?:abort|afterprint|animationend|animationiteration|animationstart|beforeinput|beforeprint|beforeunload|begin|blur|canplay|change|click|close|contextmenu|copy|cut|dblclick|drag|drop|durationchange|ended|error|focus|focusin|focusout|hashchange|input|invalid|keydown|keypress|keyup|load|loadeddata|loadedmetadata|loadstart|message|mousedown|mouseenter|mouseleave|mousemove|mouseout|mouseover|mouseup|offline|online|open|pagehide|pageshow|paste|pause|play|playing|popstate|progress|ratechange|reset|resize|scroll|search|seeked|seeking|select|show|stalled|start|storage|submit|suspend|timeupdate|toggle|touchcancel|touchend|touchmove|touchstart|transitionend|unload|volumechange|waiting|wheel)\s*=/i;
|
|
7
|
+
var DANGEROUS_PROTOCOL_PATTERN = /(?:j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t|v\s*b\s*s\s*c\s*r\s*i\s*p\s*t|l\s*i\s*v\s*e\s*s\s*c\s*r\s*i\s*p\s*t|m\s*o\s*c\s*h\s*a)\s*:|data\s*:\s*(?:text\/html|image\/svg\+xml)/i;
|
|
8
|
+
var DANGEROUS_CODE_PATTERN = /(?:\b(?:eval|expression|Function|constructor|document\.write|insertAdjacentHTML)\s*\(|@import\b|\{\{|<%|<\?|\${|#\{)/i;
|
|
9
|
+
var DROP_BODY_TAGS = /* @__PURE__ */ new Set([
|
|
10
|
+
"applet",
|
|
11
|
+
"form",
|
|
12
|
+
"frameset",
|
|
13
|
+
"head",
|
|
14
|
+
"iframe",
|
|
15
|
+
"math",
|
|
16
|
+
"noframes",
|
|
17
|
+
"noscript",
|
|
18
|
+
"object",
|
|
19
|
+
"plaintext",
|
|
20
|
+
"script",
|
|
21
|
+
"style",
|
|
22
|
+
"svg",
|
|
23
|
+
"template",
|
|
24
|
+
"textarea",
|
|
25
|
+
"title",
|
|
26
|
+
"xmp"
|
|
27
|
+
]);
|
|
28
|
+
var SAFE_URL_PROTOCOLS = /* @__PURE__ */ new Set(["http", "https", "mailto"]);
|
|
16
29
|
function escapeAngleBrackets(str) {
|
|
17
30
|
return str.replace(/</g, "<").replace(/>/g, ">");
|
|
18
31
|
}
|
|
19
|
-
function stateless(pattern) {
|
|
20
|
-
return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
|
|
21
|
-
}
|
|
22
|
-
var DETECT_DANGEROUS_TAGS = stateless(DANGEROUS_TAGS_WITH_CONTENT);
|
|
23
|
-
var DETECT_EVENT_HANDLERS = stateless(EVENT_HANDLERS_ENHANCED);
|
|
24
|
-
var DETECT_DANGEROUS_PROTOCOLS = stateless(DANGEROUS_PROTOCOLS_ENHANCED);
|
|
25
|
-
var DETECT_DANGEROUS_ATTRIBUTES = stateless(DANGEROUS_ATTRIBUTES);
|
|
26
|
-
var DETECT_TEMPLATE_INJECTION = stateless(TEMPLATE_INJECTION);
|
|
27
|
-
var DETECT_POLYGLOT_JAVASCRIPT = stateless(POLYGLOT_JAVASCRIPT);
|
|
28
|
-
var DETECT_POLYGLOT_EVENTS = stateless(POLYGLOT_EVENTS);
|
|
29
32
|
var DEFAULT_OPTIONS = {
|
|
30
33
|
maxLength: 1e6,
|
|
31
34
|
// 1MB
|
|
32
35
|
allowedProtocols: ["http", "https", "mailto"],
|
|
33
36
|
aggressiveMode: true
|
|
34
37
|
};
|
|
35
|
-
var VERSION = "2.0.
|
|
36
|
-
|
|
38
|
+
var VERSION = "2.0.3";
|
|
39
|
+
function isDisallowedScalar(codePoint) {
|
|
40
|
+
return !Number.isInteger(codePoint) || codePoint <= 0 || codePoint > 1114111 || codePoint >= 55296 && codePoint <= 57343 || codePoint < 32 && codePoint !== 9 && codePoint !== 10 && codePoint !== 13 || codePoint >= 127 && codePoint <= 159;
|
|
41
|
+
}
|
|
42
|
+
function scalarFromDigits(digits, radix) {
|
|
43
|
+
const codePoint = Number.parseInt(digits, radix);
|
|
44
|
+
return isDisallowedScalar(codePoint) ? "\uFFFD" : String.fromCodePoint(codePoint);
|
|
45
|
+
}
|
|
37
46
|
function decodeEncodingBypasses(str) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
return str.replace(/%3c/gi, "<").replace(/%3e/gi, ">").replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => scalarFromDigits(hex, 16)).replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => scalarFromDigits(hex, 16)).replace(/&#(\d+);/g, (_, digits) => scalarFromDigits(digits, 10)).replace(/&#x([0-9a-fA-F]+);/gi, (_, digits) => scalarFromDigits(digits, 16));
|
|
48
|
+
}
|
|
49
|
+
function isAsciiLetter(char) {
|
|
50
|
+
if (!char) return false;
|
|
51
|
+
const code = char.charCodeAt(0);
|
|
52
|
+
return code >= 65 && code <= 90 || code >= 97 && code <= 122;
|
|
53
|
+
}
|
|
54
|
+
function isAsciiWhitespace(char) {
|
|
55
|
+
return char === " " || char === " " || char === "\n" || char === "\r" || char === "\f";
|
|
56
|
+
}
|
|
57
|
+
function isTagNameChar(char) {
|
|
58
|
+
if (!char) return false;
|
|
59
|
+
const code = char.charCodeAt(0);
|
|
60
|
+
return isAsciiLetter(char) || code >= 48 && code <= 57 || char === ":" || char === "-";
|
|
61
|
+
}
|
|
62
|
+
var INCOMPLETE_TAG = /* @__PURE__ */ Symbol("incomplete-tag");
|
|
63
|
+
function parseTag(input, start) {
|
|
64
|
+
if (input[start] !== "<") return null;
|
|
65
|
+
let cursor = start + 1;
|
|
66
|
+
let closing = false;
|
|
67
|
+
if (input[cursor] === "/") {
|
|
68
|
+
closing = true;
|
|
69
|
+
cursor++;
|
|
70
|
+
while (isAsciiWhitespace(input[cursor])) cursor++;
|
|
42
71
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
if (!isAsciiLetter(input[cursor])) return null;
|
|
73
|
+
const nameStart = cursor;
|
|
74
|
+
while (isTagNameChar(input[cursor])) cursor++;
|
|
75
|
+
const name = input.slice(nameStart, cursor).toLowerCase();
|
|
76
|
+
const boundary = input[cursor];
|
|
77
|
+
if (boundary === void 0) return INCOMPLETE_TAG;
|
|
78
|
+
if (boundary !== ">" && boundary !== "/" && !isAsciiWhitespace(boundary)) return null;
|
|
79
|
+
if (isAsciiWhitespace(boundary)) {
|
|
80
|
+
let next = cursor;
|
|
81
|
+
while (isAsciiWhitespace(input[next])) next++;
|
|
82
|
+
if (input[next] === "&") return null;
|
|
83
|
+
}
|
|
84
|
+
let quote = "";
|
|
85
|
+
let selfClosing = false;
|
|
86
|
+
for (; cursor < input.length; cursor++) {
|
|
87
|
+
const char = input[cursor];
|
|
88
|
+
if (quote) {
|
|
89
|
+
if (char === quote) quote = "";
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (char === '"' || char === "'") {
|
|
93
|
+
quote = char;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (char === ">") {
|
|
97
|
+
let before = cursor - 1;
|
|
98
|
+
while (before > start && isAsciiWhitespace(input[before])) before--;
|
|
99
|
+
selfClosing = input[before] === "/";
|
|
100
|
+
return { closing, end: cursor + 1, name, selfClosing };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return INCOMPLETE_TAG;
|
|
104
|
+
}
|
|
105
|
+
function skipCommentOrDeclaration(input, start) {
|
|
106
|
+
if (input.startsWith("<!--", start)) {
|
|
107
|
+
const end = input.indexOf("-->", start + 4);
|
|
108
|
+
return end === -1 ? input.length : end + 3;
|
|
109
|
+
}
|
|
110
|
+
if (input.startsWith("<!", start) || input.startsWith("<?", start)) {
|
|
111
|
+
const end = input.indexOf(">", start + 2);
|
|
112
|
+
return end === -1 ? input.length : end + 1;
|
|
57
113
|
}
|
|
58
|
-
return
|
|
114
|
+
return null;
|
|
59
115
|
}
|
|
60
|
-
function
|
|
61
|
-
|
|
116
|
+
function skipContainerBody(input, cursor, containerName) {
|
|
117
|
+
let depth = 1;
|
|
118
|
+
while (cursor < input.length) {
|
|
119
|
+
const nextOpen = input.indexOf("<", cursor);
|
|
120
|
+
if (nextOpen === -1) return input.length;
|
|
121
|
+
const specialEnd = skipCommentOrDeclaration(input, nextOpen);
|
|
122
|
+
if (specialEnd !== null) {
|
|
123
|
+
cursor = specialEnd;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const tag = parseTag(input, nextOpen);
|
|
127
|
+
if (tag === INCOMPLETE_TAG) return input.length;
|
|
128
|
+
if (!tag) {
|
|
129
|
+
cursor = nextOpen + 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
cursor = tag.end;
|
|
133
|
+
if (tag.name !== containerName) continue;
|
|
134
|
+
if (tag.closing) {
|
|
135
|
+
depth--;
|
|
136
|
+
if (depth === 0) return cursor;
|
|
137
|
+
} else if (!tag.selfClosing) {
|
|
138
|
+
depth++;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return input.length;
|
|
142
|
+
}
|
|
143
|
+
function stripToText(input) {
|
|
144
|
+
const output = [];
|
|
145
|
+
let textStart = 0;
|
|
146
|
+
let cursor = 0;
|
|
147
|
+
while (cursor < input.length) {
|
|
148
|
+
const nextOpen = input.indexOf("<", cursor);
|
|
149
|
+
if (nextOpen === -1) break;
|
|
150
|
+
const specialEnd = skipCommentOrDeclaration(input, nextOpen);
|
|
151
|
+
if (specialEnd !== null) {
|
|
152
|
+
output.push(input.slice(textStart, nextOpen));
|
|
153
|
+
cursor = specialEnd;
|
|
154
|
+
textStart = cursor;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const tag = parseTag(input, nextOpen);
|
|
158
|
+
if (tag === INCOMPLETE_TAG) {
|
|
159
|
+
output.push(input.slice(textStart));
|
|
160
|
+
textStart = input.length;
|
|
161
|
+
cursor = input.length;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
if (!tag) {
|
|
165
|
+
cursor = nextOpen + 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
output.push(input.slice(textStart, nextOpen));
|
|
169
|
+
cursor = tag.end;
|
|
170
|
+
if (!tag.closing && !tag.selfClosing && DROP_BODY_TAGS.has(tag.name)) {
|
|
171
|
+
cursor = skipContainerBody(input, cursor, tag.name);
|
|
172
|
+
}
|
|
173
|
+
textStart = cursor;
|
|
174
|
+
}
|
|
175
|
+
if (textStart < input.length) output.push(input.slice(textStart));
|
|
176
|
+
const text = output.join("").replace(/\s+/g, " ").trim();
|
|
177
|
+
return text.includes("<") || text.includes(">") ? escapeAngleBrackets(text) : text;
|
|
62
178
|
}
|
|
63
|
-
function
|
|
64
|
-
|
|
179
|
+
function coerceInput(input) {
|
|
180
|
+
if (input === null || input === void 0) return "";
|
|
65
181
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
result = result.replace(/javascript\s*:\s*\/\*[^*]*\*\/[^>]*/gi, "");
|
|
71
|
-
result = result.replace(/<form[^>]*>[\s\S]*?<\/form>/gi, "");
|
|
72
|
-
result = result.replace(/<math[^>]*>[\s\S]*?<\/math>/gi, "");
|
|
73
|
-
result = result.replace(/%0[AD]/gi, "");
|
|
74
|
-
if (/svg|sVg|\x3c|\\\w{3}/i.test(result)) {
|
|
75
|
-
result = result.replace(/svg[^>]*>/gi, "");
|
|
76
|
-
result = result.replace(/sVg[^>]*>/gi, "");
|
|
77
|
-
result = result.replace(/\\x\w{2}/gi, "");
|
|
78
|
-
result = result.replace(/<\/[^>]*>/gi, "");
|
|
79
|
-
result = result.replace(/<[^>]*>/gi, "");
|
|
182
|
+
if (typeof input === "string") return input;
|
|
183
|
+
if (typeof input === "object") {
|
|
184
|
+
const serialized = JSON.stringify(input);
|
|
185
|
+
return typeof serialized === "string" ? serialized : "";
|
|
80
186
|
}
|
|
187
|
+
return String(input);
|
|
81
188
|
} catch {
|
|
82
|
-
|
|
189
|
+
return "";
|
|
83
190
|
}
|
|
84
|
-
|
|
191
|
+
}
|
|
192
|
+
function resolveMaxLength(value) {
|
|
193
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : DEFAULT_OPTIONS.maxLength;
|
|
85
194
|
}
|
|
86
195
|
function assessThreatLevel(input) {
|
|
87
196
|
if (!input) return "none";
|
|
88
|
-
if (
|
|
197
|
+
if (/<\s*\/?\s*script\b/i.test(input) || DANGEROUS_PROTOCOL_PATTERN.test(input) || EVENT_HANDLER_PATTERN.test(input)) {
|
|
89
198
|
return "critical";
|
|
90
199
|
}
|
|
91
|
-
if (
|
|
200
|
+
if (DANGEROUS_CONTAINER_PATTERN.test(input) || /\beval\s*\(/i.test(input)) {
|
|
92
201
|
return "high";
|
|
93
202
|
}
|
|
94
|
-
if (
|
|
203
|
+
if (DANGEROUS_CODE_PATTERN.test(input) || /<\s*(?:link|meta|base)\b/i.test(input)) {
|
|
95
204
|
return "medium";
|
|
96
205
|
}
|
|
97
|
-
if (/<[^>]*>/i.test(input)) {
|
|
98
|
-
return "low";
|
|
99
|
-
}
|
|
100
206
|
return "none";
|
|
101
207
|
}
|
|
102
208
|
function isDangerous(input) {
|
|
103
209
|
if (!input) return false;
|
|
104
|
-
|
|
105
|
-
return DETECT_DANGEROUS_TAGS.test(input) || DETECT_EVENT_HANDLERS.test(input) || DETECT_DANGEROUS_PROTOCOLS.test(input) || DETECT_DANGEROUS_ATTRIBUTES.test(input) || DETECT_TEMPLATE_INJECTION.test(input) || DETECT_POLYGLOT_JAVASCRIPT.test(input) || DETECT_POLYGLOT_EVENTS.test(input) || DANGEROUS_PROTOCOL_TOKEN.test(input);
|
|
106
|
-
} catch {
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
210
|
+
return DANGEROUS_CONTAINER_PATTERN.test(input) || EVENT_HANDLER_PATTERN.test(input) || DANGEROUS_PROTOCOL_PATTERN.test(input) || DANGEROUS_CODE_PATTERN.test(input);
|
|
109
211
|
}
|
|
110
212
|
function sanitize(input, options) {
|
|
111
213
|
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
str = JSON.stringify(input);
|
|
119
|
-
} else {
|
|
120
|
-
str = String(input);
|
|
121
|
-
}
|
|
122
|
-
} catch {
|
|
123
|
-
return "";
|
|
124
|
-
}
|
|
125
|
-
if (str.length > config.maxLength) {
|
|
126
|
-
str = str.substring(0, config.maxLength);
|
|
214
|
+
let str = coerceInput(input);
|
|
215
|
+
const maxLength = resolveMaxLength(config.maxLength);
|
|
216
|
+
if (str.length > maxLength) str = str.slice(0, maxLength);
|
|
217
|
+
if (!str) return "";
|
|
218
|
+
if (!str.includes("<") && !str.includes(">") && !HAS_CONTROL_CHARS.test(str) && !ENCODED_SYNTAX.test(str)) {
|
|
219
|
+
return str.replace(/\s+/g, " ").trim();
|
|
127
220
|
}
|
|
128
|
-
if (!str.trim()) return "";
|
|
129
|
-
let result = str.replace(NULL_CONTROL_CHARS, "");
|
|
130
221
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!dangerous) {
|
|
134
|
-
const benign = removeDangerousTagsWithContent(result).replace(HTML_TAG, "").replace(DANGEROUS_ATTRIBUTES, "").replace(TEMPLATE_INJECTION, "").replace(DANGEROUS_FUNCTIONS, "");
|
|
135
|
-
return escapeAngleBrackets(benign).replace(/\s+/g, " ").trim();
|
|
136
|
-
}
|
|
137
|
-
result = removeDangerousTagsWithContent(result);
|
|
138
|
-
result = handlePolyglotAttacks(result);
|
|
139
|
-
let previous;
|
|
140
|
-
let passes = 0;
|
|
141
|
-
do {
|
|
142
|
-
previous = result;
|
|
143
|
-
result = result.replace(DANGEROUS_TAGS_WITH_CONTENT, "").replace(EVENT_HANDLERS_ENHANCED, "").replace(DANGEROUS_PROTOCOLS_ENHANCED, (match) => {
|
|
144
|
-
const safeProtocols = config.allowedProtocols.join("|");
|
|
145
|
-
return match.match(new RegExp(`^(?:href|src)\\s*=\\s*["']?\\s*(?:${safeProtocols}):\\/\\/`, "i")) ? match : "";
|
|
146
|
-
}).replace(DANGEROUS_ATTRIBUTES, "").replace(TEMPLATE_INJECTION, "").replace(DANGEROUS_FUNCTIONS, "");
|
|
147
|
-
passes++;
|
|
148
|
-
} while (result !== previous && passes < MAX_FILTER_PASSES);
|
|
149
|
-
if (config.aggressiveMode && SUSPICIOUS_PATTERNS.test(result)) {
|
|
150
|
-
const tempCheck = result.replace(/<[^>]*>/g, "");
|
|
151
|
-
if (SUSPICIOUS_PATTERNS.test(tempCheck)) {
|
|
152
|
-
result = result.replace(/<[^>]*>/g, "").replace(/[<>]/g, "").replace(/javascript|vbscript/gi, "").replace(/\bon[a-z]+\s*=/gi, "").replace(/svg|SVG|sVg/gi, "").replace(/script|SCRIPT/gi, "").replace(/alert|eval/gi, "").replace(/\\x\w{2}/gi, "").replace(/[(){}[\]]/g, "");
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
result = result.replace(/\s+/g, " ").trim();
|
|
156
|
-
if (config.aggressiveMode && !HAS_TEXT_CONTENT.test(result)) {
|
|
157
|
-
return "";
|
|
158
|
-
}
|
|
159
|
-
return escapeAngleBrackets(result);
|
|
222
|
+
const decoded = decodeEncodingBypasses(str.replace(NULL_CONTROL_CHARS, "")).replace(NULL_CONTROL_CHARS, "");
|
|
223
|
+
return stripToText(decoded);
|
|
160
224
|
} catch {
|
|
161
|
-
return str.replace(NULL_CONTROL_CHARS, "")
|
|
225
|
+
return escapeAngleBrackets(str.replace(NULL_CONTROL_CHARS, "")).replace(/\s+/g, " ").trim();
|
|
162
226
|
}
|
|
163
227
|
}
|
|
164
228
|
function analyze(input, options) {
|
|
165
229
|
const startTime = performance.now();
|
|
166
|
-
const
|
|
167
|
-
const
|
|
230
|
+
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
231
|
+
const originalStr = coerceInput(input).slice(0, resolveMaxLength(config.maxLength));
|
|
232
|
+
const sanitized = sanitize(originalStr, options);
|
|
168
233
|
const processingTime = performance.now() - startTime;
|
|
169
|
-
const
|
|
170
|
-
const threatLevel = assessThreatLevel(
|
|
234
|
+
const decodedForAnalysis = decodeEncodingBypasses(originalStr.replace(NULL_CONTROL_CHARS, ""));
|
|
235
|
+
const threatLevel = assessThreatLevel(decodedForAnalysis);
|
|
236
|
+
const hadThreats = isDangerous(originalStr) || isDangerous(decodedForAnalysis);
|
|
171
237
|
return {
|
|
172
238
|
content: sanitized,
|
|
173
239
|
hadThreats,
|
|
@@ -211,14 +277,17 @@ function escapeUrl(input, options) {
|
|
|
211
277
|
const str = (typeof input === "string" ? input : String(input)).trim();
|
|
212
278
|
if (!str) return "";
|
|
213
279
|
const normalized = str.replace(/[\x00-\x20\x7F-\x9F]/g, "");
|
|
280
|
+
if (normalized.startsWith("//") || normalized.startsWith("\\\\")) return "";
|
|
281
|
+
const configuredProtocols = Array.isArray(config.allowedProtocols) ? config.allowedProtocols : [];
|
|
282
|
+
const requestedProtocols = new Set(
|
|
283
|
+
configuredProtocols.filter((protocol) => typeof protocol === "string").map((protocol) => protocol.toLowerCase()).filter((protocol) => SAFE_URL_PROTOCOLS.has(protocol))
|
|
284
|
+
);
|
|
214
285
|
const colon = normalized.indexOf(":");
|
|
215
286
|
if (colon !== -1) {
|
|
216
287
|
const beforeColon = normalized.slice(0, colon);
|
|
217
288
|
if (!/[/?#]/.test(beforeColon)) {
|
|
218
289
|
const protocol = beforeColon.toLowerCase();
|
|
219
|
-
if (!
|
|
220
|
-
return "";
|
|
221
|
-
}
|
|
290
|
+
if (!requestedProtocols.has(protocol)) return "";
|
|
222
291
|
}
|
|
223
292
|
}
|
|
224
293
|
return escapeAttribute(normalized);
|
|
@@ -229,17 +298,17 @@ function getVersion() {
|
|
|
229
298
|
function getStats() {
|
|
230
299
|
return {
|
|
231
300
|
version: getVersion(),
|
|
232
|
-
securityLevel: "
|
|
233
|
-
performance: "
|
|
301
|
+
securityLevel: "Plain-text output with contextual encoders",
|
|
302
|
+
performance: "Measure with the included benchmark on your target runtime"
|
|
234
303
|
};
|
|
235
304
|
}
|
|
236
305
|
var Purifai = class {
|
|
237
306
|
/**
|
|
238
|
-
*
|
|
307
|
+
* Convert HTML-like input to plain text
|
|
239
308
|
*
|
|
240
309
|
* @param input - Content to sanitize (string, object, or any type)
|
|
241
310
|
* @param options - Optional configuration
|
|
242
|
-
* @returns
|
|
311
|
+
* @returns Plain-text string; render it through normal text interpolation
|
|
243
312
|
*
|
|
244
313
|
* @example
|
|
245
314
|
* ```typescript
|
|
@@ -364,11 +433,11 @@ export {
|
|
|
364
433
|
sanitizeBatch
|
|
365
434
|
};
|
|
366
435
|
/**
|
|
367
|
-
*
|
|
436
|
+
* PURIFAI - bounded strip-to-text sanitizer and contextual encoders.
|
|
368
437
|
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
438
|
+
* `sanitize()` removes markup; it does not preserve safe HTML. Use `escape()`,
|
|
439
|
+
* `escapeAttribute()`, or `escapeUrl()` when the destination context is known.
|
|
371
440
|
*
|
|
372
|
-
* @version 2.0.
|
|
441
|
+
* @version 2.0.3
|
|
373
442
|
* @license MIT
|
|
374
443
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "purifai",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "Zero-dependency strip-to-text HTML sanitizer with contextual output encoding (HTML, attribute, URL). ~4.
|
|
3
|
+
"version": "2.0.3",
|
|
4
|
+
"description": "Zero-dependency strip-to-text HTML sanitizer with contextual output encoding (HTML, attribute, URL). ~4.5KB gzipped, no DOM required - runs in Node, browsers and edge runtimes.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -21,14 +21,16 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "tsup index.ts --format cjs,esm --clean && pnpm run build:dts",
|
|
23
23
|
"build:dts": "tsc -p tsconfig.dts.json && node -e \"require('fs').copyFileSync('dist/index.d.ts','dist/index.d.cts')\"",
|
|
24
|
-
"test": "pnpm run build && node test/security-test.js && node test/regression-test.js && node test/fuzz-test.js && node test/fair-benchmark.js",
|
|
24
|
+
"test": "pnpm run build && node test/security-test.js && node test/regression-test.js && node --test test/consumer-example.test.mjs && node test/fuzz-test.js && node test/fair-benchmark.js",
|
|
25
25
|
"test:fuzz": "node test/fuzz-test.js",
|
|
26
26
|
"test:security": "node test/security-test.js",
|
|
27
27
|
"test:regression": "node test/regression-test.js",
|
|
28
|
+
"test:example": "pnpm run build && node --test test/consumer-example.test.mjs",
|
|
28
29
|
"test:bench": "node test/comprehensive-test.js",
|
|
29
30
|
"test:fair": "node test/fair-benchmark.js",
|
|
30
31
|
"test:perf": "node test/verify-performance.js",
|
|
31
|
-
"
|
|
32
|
+
"test:size": "node test/bundle-size-comparison.js",
|
|
33
|
+
"benchmark": "pnpm run build && node test/fair-benchmark.js && node test/verify-performance.js && node test/bundle-size-comparison.js",
|
|
32
34
|
"prepublishOnly": "pnpm run test",
|
|
33
35
|
"dev": "tsup index.ts --format cjs,esm --watch",
|
|
34
36
|
"typecheck": "tsc --noEmit",
|
|
@@ -72,7 +74,9 @@
|
|
|
72
74
|
"@types/node": "^26.0.0",
|
|
73
75
|
"chalk": "^6.0.0",
|
|
74
76
|
"cli-table3": "^0.6.5",
|
|
77
|
+
"dompurify": "^3.4.12",
|
|
75
78
|
"entities": "^8.0.0",
|
|
79
|
+
"esbuild": "^0.27.7",
|
|
76
80
|
"escape-html": "^1.0.3",
|
|
77
81
|
"he": "^1.2.0",
|
|
78
82
|
"html-entities": "^2.6.0",
|