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/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 POLYGLOT_JAVASCRIPT = /j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:/gi;
10
- var POLYGLOT_EVENTS = /o\s*n\s*[a-z]+\s*(?:=|alert)/gi;
11
- var ENCODED_TAGS = /\\x3c|\\x3e|%3c|%3e/gi;
12
- var SUSPICIOUS_PATTERNS = /<|>|javascript:|vbscript:|\bon[a-z]+\s*=|@import|\{\{|<%|<\?|\${|#\{/i;
13
- var DANGEROUS_PROTOCOL_TOKEN = /(?: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/i;
14
- var HAS_TEXT_CONTENT = /[a-z0-9]/i;
15
- var HTML_TAG = /<\/?[a-zA-Z][^>]*>/g;
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, "&lt;").replace(/>/g, "&gt;");
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.2";
36
- var MAX_FILTER_PASSES = 5;
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
- let result = str;
39
- try {
40
- result = decodeURIComponent(result);
41
- } catch {
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
- try {
44
- result = result.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
45
- return String.fromCharCode(parseInt(hex, 16));
46
- });
47
- result = result.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => {
48
- return String.fromCharCode(parseInt(hex, 16));
49
- });
50
- result = result.replace(/&#(\d+);/g, (_, num) => {
51
- return String.fromCharCode(parseInt(num, 10));
52
- });
53
- result = result.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
54
- return String.fromCharCode(parseInt(hex, 16));
55
- });
56
- } catch {
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 result;
114
+ return null;
59
115
  }
60
- function removeDangerousTagsWithContent(str) {
61
- return str.replace(DANGEROUS_TAGS_WITH_CONTENT, "");
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 handlePolyglotAttacks(str) {
64
- let result = str;
179
+ function coerceInput(input) {
180
+ if (input === null || input === void 0) return "";
65
181
  try {
66
- result = result.replace(POLYGLOT_JAVASCRIPT, "");
67
- result = result.replace(POLYGLOT_EVENTS, "");
68
- result = result.replace(ENCODED_TAGS, "");
69
- result = result.replace(/javascript\s*:\s*\/\*[\s\S]*?\*\//gi, "");
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
- result = result.replace(/<[^>]*>/g, "").replace(/[<>]/g, "");
189
+ return "";
83
190
  }
84
- return result;
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 (/<script/i.test(input) || /javascript\s*:/i.test(input) || /\bon[a-z]+\s*=/i.test(input)) {
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 (/<(iframe|object|embed|form|svg|math)/i.test(input) || /eval\s*\(/i.test(input)) {
200
+ if (DANGEROUS_CONTAINER_PATTERN.test(input) || /\beval\s*\(/i.test(input)) {
92
201
  return "high";
93
202
  }
94
- if (/<(style|link|meta)/i.test(input) || /expression\s*\(/i.test(input)) {
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
- try {
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
- if (input === null || input === void 0) return "";
113
- let str;
114
- try {
115
- if (typeof input === "string") {
116
- str = input;
117
- } else if (typeof input === "object") {
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
- result = decodeEncodingBypasses(result);
132
- const dangerous = isDangerous(str) || isDangerous(result);
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, "").replace(/<[^>]*>/g, "").replace(/[<>]/g, "").replace(/javascript|vbscript|eval|alert/gi, "").replace(/\bon[a-z]+\s*=/gi, "").replace(/\s+/g, " ").trim();
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 originalStr = String(input ?? "");
167
- const sanitized = sanitize(input, options);
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 hadThreats = sanitized !== originalStr;
170
- const threatLevel = assessThreatLevel(originalStr);
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 (!config.allowedProtocols.map((p) => p.toLowerCase()).includes(protocol)) {
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: "100% XSS Protection",
233
- performance: "Optimized for high-throughput"
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
- * Sanitize input with maximum security protection
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 Sanitized string safe for HTML output
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
- * 🛡️ PURIFAI - Ultra-Secure HTML Sanitizer
436
+ * PURIFAI - bounded strip-to-text sanitizer and contextual encoders.
368
437
  *
369
- * Advanced XSS protection with polyglot attack resistance.
370
- * Blocks sophisticated obfuscation techniques that bypass other sanitizers.
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.2
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.2",
4
- "description": "Zero-dependency strip-to-text HTML sanitizer with contextual output encoding (HTML, attribute, URL). ~4.1KB gzipped, no DOM required - runs in Node, browsers and edge runtimes.",
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
- "benchmark": "pnpm run build && node test/fair-benchmark.js && node test/verify-performance.js",
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",