url-sanitizer 2.3.1 → 4.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 CHANGED
@@ -7,31 +7,34 @@
7
7
 
8
8
  A robust URL sanitizer for Node.js, browsers, and websites.
9
9
  It sanitizes not only regular URLs but also deeply inspects `data` URLs and `blob` URLs.
10
- It also provides built-in utilities to parse URLs and verify URI schemes.
11
-
12
- ## Features
13
-
14
- * **Secure by Default**: Automatically denies `javascript:` and `vbscript:` schemes.
15
- * **Deep Data URL Inspection**: Parses, decodes (including base64), and sanitizes nested data URLs.
16
- * **Blob URL Support**: Converts and sanitizes blob URLs into safe data URLs (async).
17
- * **Relative & Absolute Path Support**: Safely allows same-origin absolute paths and relative paths via an opt-in parameter.
18
- * **DOMPurify Integration**: Purifies HTML/SVG content embedded within data URLs. *Note: This library is optimized for URL sanitization and is not intended as a complete HTML sanitizer for arbitrary untrusted markup.*
19
- * **Pure ESM with TypeScript Support**: Works seamlessly across modern environments like Node.js, Deno, websites, and browsers (including WebExtensions).
10
+ It also provides built-in utilities to inspect URLs and verify URI schemes.
20
11
 
21
12
  ## Table of Contents
13
+ * [Features](#features)
22
14
  * [Install](#install)
23
15
  * [Usage](#usage)
24
16
  * [API Reference](#api-reference)
25
17
  * [sanitizeURL(url, opt)](#sanitizeurlurl-opt)
26
18
  * [sanitizeURLSync(url, opt)](#sanitizeurlsyncurl-opt)
27
- * [parseURL(url)](#parseurlurl)
28
- * [parseURLSync(url)](#parseurlsyncurl)
19
+ * [inspectURL(url)](#inspecturlurl)
20
+ * [inspectURLSync(url)](#inspecturlsyncurl)
29
21
  * [isURI(uri)](#isuriuri)
30
22
  * [isURISync(uri)](#isurisyncuri)
31
23
  * [urlSanitizer Instance](#urlsanitizer)
24
+ * [Threat Model](#threat-model)
25
+ * [AI / LLM Application Security](#ai--llm-application-security)
32
26
  * [Performance](#performance)
33
27
  * [Acknowledgments](#acknowledgments)
34
28
 
29
+ ## Features
30
+
31
+ * **Secure by Default**: Strictly blocks `javascript:` and `vbscript:` schemes.
32
+ * **Deep Data URL Inspection**: Parses, decodes (including base64), and sanitizes nested data URLs.
33
+ * **Blob URL Support**: Converts and sanitizes blob URLs into safe data URLs (async).
34
+ * **Relative & Absolute Path Support**: Safely allows root-relative paths (e.g., `/foo`) and relative paths (e.g., `./foo`) via an opt-in parameter.
35
+ * **DOMPurify Integration**: Purifies HTML/SVG content embedded within data URLs.
36
+ * **Pure ESM with TypeScript Support**: Works seamlessly across modern environments like Node.js, Deno, websites, and browsers (including WebExtensions).
37
+
35
38
  ## Install
36
39
 
37
40
  ### Node.js
@@ -42,11 +45,13 @@ npm i url-sanitizer
42
45
 
43
46
  ### Deno
44
47
 
45
- You can import directly in your code without installing:
46
- ``` javascript
47
- import urlSanitizer from "npm:url-sanitizer";
48
+ ```javascript
49
+ import urlSanitizer from 'npm:url-sanitizer';
48
50
  ```
49
51
 
52
+ **Note:** This library assumes a Node.js/npm-compatible dependency resolution environment, so we recommend using the `npm:` specifier.
53
+ CDN-based ESM imports (e.g., `esm.sh`, `jsdelivr`, or `unpkg`) may work, but are not tested or guaranteed.
54
+
50
55
  ### Browsers and Websites
51
56
 
52
57
  Standalone ESM builds are available in the `dist/` directory:
@@ -55,14 +60,26 @@ Standalone ESM builds are available in the `dist/` directory:
55
60
  * node_modules/url-sanitizer/dist/url-sanitizer-wo-dompurify.min.js
56
61
 
57
62
  Alternatively, download them from [Releases](https://github.com/asamuzaK/urlSanitizer/releases).
58
- **NOTE:** url-sanitizer-wo-dompurify.min.js is a lightweight build without [DOMPurify](https://www.npmjs.com/package/dompurify) bundled.
63
+ ``` html
64
+ <script type="module">
65
+ import urlSanitizer from 'path/to/url-sanitizer.min.js';
66
+ </script>
67
+ ```
68
+
69
+ **Note:** `url-sanitizer-wo-dompurify.min.js` is a lightweight build without [DOMPurify](https://www.npmjs.com/package/dompurify) bundled.
59
70
  If you use this build, ensure DOMPurify is exposed globally (e.g., `window.DOMPurify`), otherwise the sanitizer will throw an error at runtime.
71
+ ``` html
72
+ <script src="https://unpkg.com/dompurify/dist/purify.min.js"></script>
73
+ <script type="module">
74
+ import urlSanitizer from 'path/to/url-sanitizer-wo-dompurify.min.js';
75
+ </script>
76
+ ```
60
77
 
61
78
  ## Usage
62
79
 
63
80
  ``` javascript
64
81
  import urlSanitizer, {
65
- isURI, isURISync, parseURL, parseURLSync, sanitizeURL, sanitizeURLSync
82
+ isURI, isURISync, inspectURL, inspectURLSync, sanitizeURL, sanitizeURLSync
66
83
  } from 'url-sanitizer';
67
84
  ```
68
85
 
@@ -73,79 +90,104 @@ import urlSanitizer, {
73
90
  Sanitizes the given URL asynchronously.
74
91
 
75
92
  * `blob`, `data`, and `file` schemes must be explicitly allowed.
76
- * Given a blob URL, it returns a sanitized **data** URL.
93
+ * <a name="about-file-scheme">**WARNING**</a><br>
94
+ **File URLs:** Allowing the `file` scheme can be extremely dangerous in web applications, as it may expose the host's local file system to attacks like Local File Inclusion (LFI). Only allow it if you fully trust the input or are operating in a strictly isolated local environment.
95
+ * **Blob URLs:** Given a **blob** URL, it converts and returns a sanitized **data** URL.
96
+ * The sanitized data URL will **not** be converted back to a blob URL.
97
+ * It is highly recommended to set the `opt.revokeObjectURL` option to `true` when you allow `blob` URLs.
98
+ * Converting a blob URL to a data URL consumes memory, so be aware of the risk of memory exhaustion when handling huge blobs.
99
+ * You can restrict the allowed blob size using the `opt.maxBlobSize` option.
100
+ Default is `16_777_216` (16MB).
101
+ While 16MB may be suitable for client-side processing, it is highly recommended to reduce this limit to `1_048_576` (1MB) or `2_097_152` (2MB) in server-side applications.
102
+ Parsing large HTML/SVG contents inside Data/Blob URLs via JSDOM can consume massive amounts of V8 heap memory.
77
103
 
78
104
  #### Parameters
79
105
 
80
106
  * url **string** URL input.
81
- * opt **object?** Options.
82
- * opt.allow **Array<string>?** Array of allowed schemes, e.g., ['data'].
83
- * opt.deny **Array<string>?** Array of denied schemes, e.g., ['web+foo'].
84
- * opt.only **Array<string>?** Array of specific schemes to allow, e.g., ['git', 'https'].
107
+ * opt **\[object\]** Options.
108
+ * opt.allow **\[Array&lt;string&gt;\]** Array of allowed schemes, e.g., \['data'\].
109
+ * opt.deny **\[Array&lt;string&gt;\]** Array of denied schemes, e.g., \['web+foo'\].
110
+ * opt.only **\[Array&lt;string&gt;\]** Array of specific schemes to allow, e.g., \['git', 'https'\].
85
111
  `only` takes precedence over `allow` and `deny`.
86
- * opt.allowRelative **boolean?** If `true`, allows safe same-origin absolute paths and relative paths. Default is `false`.
87
- * opt.debug **boolean?** If `true`, outputs internal error/warning logs to the console. Default is `false`.
88
- * opt.maxBlobSize **number?** Maximum allowed blob size in bytes. Default is `33554432` (32MB). Exceeding this limit will result in parsing failure to prevent memory exhaustion.
112
+ * opt.allowRelative **\[boolean\]** If `true`, allows root-relative paths (e.g. `/foo`) and relative paths (e.g. `./foo`). Default is `false`.
113
+ * opt.debug **\[boolean\]** If `true`, outputs internal error/warning logs to the console. Default is `false`.
114
+ * opt.revokeObjectURL **\[boolean\]** Revokes the blob URL after sanitization. Default is `false`. However, to prevent memory leaks, it is highly recommended to set this option to `true`.
115
+ * opt.maxBlobSize **\[number\]** Maximum allowed blob size in bytes. Default is `16_777_216` (16MB).
89
116
 
90
- **Returns** **Promise<string?>** Sanitized URL, nullable.
117
+ **Returns** **Promise&lt;string?&gt;** Sanitized URL, nullable.
91
118
 
92
119
  #### Samples
93
120
 
121
+ Never allow `javascript:` and/or `vbscript:` schemes:
122
+ ``` javascript
123
+ const res1 = await sanitizeURL('javascript:alert(1)');
124
+ // => null
125
+
126
+ const res1_2 = await sanitizeURL('vbscript:msgbox("XSS")');
127
+ // => null
128
+
129
+ // Even if explicitly added to the `allow` or `only` list, they are strictly blocked:
130
+ const res1_3 = await sanitizeURL('javascript:alert(1)', {
131
+ allow: ['javascript']
132
+ });
133
+ // => null
134
+ ```
135
+
94
136
  Sanitize tags and quotes:
95
137
  ``` javascript
96
- const res1 = await sanitizeURL('https://example.com/?<script>alert(1)</script>');
138
+ const res2 = await sanitizeURL('https://example.com/?<script>alert(1)</script>');
97
139
  // => 'https://example.com/'
98
140
 
99
- const res1_2 = await sanitizeURL('https://example.com/" onclick="alert(1)"');
141
+ const res2_2 = await sanitizeURL('https://example.com/" onclick="alert(1)"');
100
142
  // => 'https://example.com/'
101
143
  ```
102
144
 
103
145
  Parse and sanitize data URLs:
104
146
  ``` javascript
105
- const res2 = await sanitizeURL('data:text/html,<div><script>alert(1);</script></div><p onclick="alert(2)"></p>', {
147
+ const res3 = await sanitizeURL('data:text/html,<div><script>alert(1);</script></div><p onclick="alert(2)"></p>', {
106
148
  allow: ['data']
107
149
  });
108
150
  // => 'data:text/html,%3Cdiv%3E%3C/div%3E%3Cp%3E%3C/p%3E'
109
151
 
110
- console.log(decodeURIComponent(res2));
152
+ console.log(decodeURIComponent(res3));
111
153
  // => 'data:text/html,<div></div><p></p>'
112
154
 
113
155
  // Also can parse and sanitize base64 encoded data:
114
- const base64data3 = btoa('<div><script>alert(1);</script></div>');
115
- const res3 = await sanitizeURL(`data:text/html;base64,${base64data3}`, {
156
+ const base64data4 = btoa('<div><script>alert(1);</script></div>');
157
+ const res4 = await sanitizeURL(`data:text/html;base64,${base64data4}`, {
116
158
  allow: ['data']
117
159
  });
118
160
  // => 'data:text/html,%3Cdiv%3E%3C/div%3E'
119
161
 
120
- console.log(decodeURIComponent(res3));
162
+ console.log(decodeURIComponent(res4));
121
163
  // => 'data:text/html,<div></div>'
122
164
 
123
- const base64data3_2 = btoa('<div><img src="javascript:alert(1)"></div>');
124
- const res3_2 = await sanitizeURL(`data:text/html;base64,${base64data3_2}`);
165
+ const base64data4_2 = btoa('<div><img src="javascript:alert(1)"></div>');
166
+ const res4_2 = await sanitizeURL(`data:text/html;base64,${base64data4_2}`);
125
167
  // => 'data:text/html,%3Cdiv%3E%3Cimg%3E%3C/div%3E'
126
168
 
127
- console.log(decodeURIComponent(res3_2));
169
+ console.log(decodeURIComponent(res4_2));
128
170
  // => 'data:text/html,<div><img></div>'
129
171
  ```
130
172
 
131
173
  Parse and sanitize blob URLs:
132
174
  ``` javascript
133
- const blob4 = new Blob(['<svg><g onload="alert(1)"/></svg>'], {
175
+ const blob5 = new Blob(['<svg><g onload="alert(1)"/></svg>'], {
134
176
  type: 'image/svg+xml'
135
177
  });
136
- const url4 = URL.createObjectURL(blob4);
137
- const res4 = await sanitizeURL(url4, {
178
+ const url5 = URL.createObjectURL(blob5);
179
+ const res5 = await sanitizeURL(url5, {
138
180
  allow: ['blob']
139
181
  });
140
182
  // => 'data:image/svg+xml,%3Csvg%3E%3Cg%3E%3C/g%3E%3C/svg%3E'
141
183
 
142
- console.log(decodeURIComponent(res4));
184
+ console.log(decodeURIComponent(res5));
143
185
  // => 'data:image/svg+xml,<svg><g></g></svg>'
144
186
  ```
145
187
 
146
188
  Denies if the scheme matches the `deny` list:
147
189
  ``` javascript
148
- const res5 = await sanitizeURL('web+foo://example.com', {
190
+ const res6 = await sanitizeURL('web+foo://example.com', {
149
191
  deny: ['web+foo']
150
192
  });
151
193
  // => null
@@ -153,18 +195,18 @@ const res5 = await sanitizeURL('web+foo://example.com', {
153
195
 
154
196
  Allows only if the scheme matches the `only` list:
155
197
  ``` javascript
156
- const res6 = await sanitizeURL('http://example.com', {
198
+ const res7 = await sanitizeURL('http://example.com', {
157
199
  only: ['data', 'git', 'https']
158
200
  });
159
201
  // => null
160
202
 
161
- const res6_2 = await sanitizeURL('https://example.com/"onmouseover="alert(1)"', {
203
+ const res7_2 = await sanitizeURL('https://example.com/"onmouseover="alert(1)"', {
162
204
  only: ['data', 'git', 'https']
163
205
  });
164
206
  // => 'https://example.com/'
165
207
 
166
208
  // `only` also allows combination of the schemes in the list
167
- const res7 = await sanitizeURL('git+https://example.com/foo.git?<script>alert(1)</script>', {
209
+ const res8 = await sanitizeURL('git+https://example.com/foo.git?<script>alert(1)</script>', {
168
210
  only: ['data', 'git', 'https']
169
211
  });
170
212
  // => 'git+https://example.com/foo.git'
@@ -175,40 +217,48 @@ const res7 = await sanitizeURL('git+https://example.com/foo.git?<script>alert(1)
175
217
  Synchronous version of `sanitizeURL()`.
176
218
 
177
219
  * `data` and `file` schemes must be explicitly allowed.
178
- * `blob` scheme is not supported and will return `null`. Use the async version for `blob`.
220
+ * **File URLs:** Allowing the `file` scheme can be extremely dangerous in web applications. [See above](#about-file-scheme)
221
+ * **Blob URLs:** `blob` scheme is **not supported** and will return `null`.
222
+ Use the async version for `blob`.
223
+ However, to prevent memory leaks, it is highly recommended to set the `opt.revokeObjectURL` option to `true` so that the unsupported blob URL is properly revoked before returning `null`.
224
+
225
+ ### inspectURL(url)
179
226
 
180
- ### parseURL(url)
227
+ **Note:** `parseURL()` is deprecated. Please use `inspectURL()` instead.
181
228
 
182
- Parses the given URL asynchronously.
229
+ Inspects, parses, and sanitizes the given URL asynchronously.
183
230
 
184
- * Blob URLs are simply parsed and not yet sanitized at this stage.
231
+ * **Data URLs:** The embedded payload is fully decoded and sanitized (e.g., removing malicious HTML/SVG attributes) before being safely re-encoded.
232
+ * **Blob URLs:** Simply parsed, but **neither decoded nor sanitized** at this stage.
233
+ To process and sanitize the content of a blob URL, use [sanitizeURL()](#sanitizeurlurl-opt).
185
234
 
186
235
  #### Parameters
187
236
 
188
237
  * url **string** URL input.
189
238
 
190
- **Returns** **Promise<ParsedURL>** Result.
239
+ **Returns** **Promise&lt;InspectedURLResult&gt;** Result.
191
240
 
192
- #### ParsedURL
241
+ #### InspectedURLResult
193
242
 
194
- An object with additional properties based on the standard [URL](https://developer.mozilla.org/ja/docs/Web/API/URL) API.
243
+ An object extending the standard [URL](https://developer.mozilla.org/ja/docs/Web/API/URL) API with additional properties.
244
+ The properties except for `input` and `valid` are omitted from the object for invalid URLs.
195
245
 
196
- * `input` **string** URL input.
197
- * `valid` **boolean** Is valid URI.
198
- * `data` **object?** Parsed result of data URL, nullable.
199
- * `data.mime` **string?** MIME type.
200
- * `data.base64` **boolean?** Is base64 encoded.
201
- * `data.data` **string?** Data part of the data URL.
202
- * `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, `hash` — **string?** Properties same as the standard URL API.
246
+ * `input` — **string** The original URL input.
247
+ * `valid` — **boolean** Indicates whether the URI is valid.
248
+ * `data` — **[{ mime: string; base64: boolean; data: string; } | null | undefined]** The parsed result of a data URL. Returns `null` if the URL is valid but not a data URL, and is omitted if the URL is invalid.
249
+ * `data.mime` — **string** The MIME type of the data.
250
+ * `data.base64` — **boolean** Indicates whether the data is base64-encoded.
251
+ * `data.data` — **string** The actual data part of the data URL.
252
+ * `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, `hash` — **[string | undefined]** Properties identical to the standard URL API (omitted if the URL is invalid).
203
253
 
204
254
  ``` javascript
205
- const res1 = await parseURL('javascript:alert(1)');
255
+ const res1 = await inspectURL('javascript:alert(1)');
206
256
  /* => {
207
257
  input: 'javascript:alert(1)',
208
258
  valid: false
209
259
  } */
210
260
 
211
- const res2 = await parseURL('https://www.example.com/?foo=bar#baz');
261
+ const res2 = await inspectURL('https://www.example.com/?foo=bar#baz');
212
262
  /* => {
213
263
  input: 'https://www.example.com/?foo=bar#baz',
214
264
  valid: true,
@@ -224,7 +274,7 @@ const res2 = await parseURL('https://www.example.com/?foo=bar#baz');
224
274
  } */
225
275
 
226
276
  // base64 encoded SVG '<svg><g onclick="alert(1)"/></svg>'
227
- const res3 = await parseURL('data:image/svg+xml;base64,PHN2Zz48ZyBvbmNsaWNrPSJhbGVydCgxKSIvPjwvc3ZnPg==');
277
+ const res3 = await inspectURL('data:image/svg+xml;base64,PHN2Zz48ZyBvbmNsaWNrPSJhbGVydCgxKSIvPjwvc3ZnPg==');
228
278
  /* => {
229
279
  input: 'data:image/svg+xml;base64,PHN2Zz48ZyBvbmNsaWNrPSJhbGVydCgxKSIvPjwvc3ZnPg==',
230
280
  valid: true,
@@ -241,7 +291,7 @@ const res3 = await parseURL('data:image/svg+xml;base64,PHN2Zz48ZyBvbmNsaWNrPSJhb
241
291
  } */
242
292
 
243
293
  // base64 encoded PNG
244
- const res4 = await parseURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==');
294
+ const res4 = await inspectURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==');
245
295
  /* => {
246
296
  input: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==',
247
297
  valid: true,
@@ -262,7 +312,7 @@ const blob5 = new Blob(['<svg><g onload="alert(1)"/></svg>'], {
262
312
  type: 'image/svg+xml'
263
313
  });
264
314
  const url5 = URL.createObjectURL(blob5);
265
- const res5 = await parseURL(url5);
315
+ const res5 = await inspectURL(url5);
266
316
  /* => {
267
317
  input: 'blob:nodedata:82ecc5a4-aea8-48d7-a407-64e2ef0913da',
268
318
  valid: true,
@@ -275,22 +325,28 @@ const res5 = await parseURL(url5);
275
325
  } */
276
326
  ```
277
327
 
278
- ### parseURLSync(url)
328
+ ### inspectURLSync(url)
279
329
 
280
- Synchronous version of `parseURL()`.
330
+ **Note:** `parseURLSync()` is deprecated. Please use `inspectURLSync()` instead.
331
+
332
+ Synchronous version of `inspectURL()`.
281
333
 
282
334
  ### isURI(uri)
283
335
 
284
- Verifies if the given URI is valid and registered.
336
+ Checks if the given string is a valid URI and whether its scheme is registered in the allowed list.
337
+
338
+ Specifically, it verifies that the input has a correct URI syntax, is not a denied scheme (like `javascript:`), and meets one of the following criteria:
339
+ * The scheme is registered in the default list (e.g., IANA registered schemes, `https`, `mailto`).
340
+ * The scheme has a custom prefix like `web+` or `ext+` (which are always allowed for web applications).
285
341
 
286
342
  #### Parameters
287
343
 
288
344
  * uri **string** URI input.
289
345
 
290
- **Returns** **Promise<boolean>** Result.
346
+ **Returns** **Promise&lt;boolean&gt;** `true` if the URI is syntactically valid and uses an allowed/registered scheme; otherwise `false`.
291
347
 
292
- * Always `true` for `web+*` and `ext+*` schemes (except `web+javascript`, `web+vbscript`, `ext+javascript`, and `ext+vbscript`).
293
- * `false` for `javascript` and `vbscript` schemes.
348
+ * Always `true` for valid `web+*` and `ext+*` schemes (except `web+javascript`, `web+vbscript`, `ext+javascript`, and `ext+vbscript`).
349
+ * Always `false` for `javascript` and `vbscript` schemes, or any unknown/unregistered schemes (e.g., `foo:`).
294
350
 
295
351
  ``` javascript
296
352
  const res1 = await isURI('https://example.com/foo');
@@ -341,7 +397,43 @@ Remove a scheme from the list of registered URI schemes.
341
397
 
342
398
  #### urlSanitizer.reset()
343
399
 
344
- Reset registered schemes to the default list and clear internal cache.
400
+ Reset registered schemes to the default list.
401
+
402
+ ## Threat Model
403
+
404
+ To help you decide if this library fits your security requirements, here is the defined threat model detailing what `url-sanitizer` does and does not protect against.
405
+
406
+ ### In-Scope (What we protect against)
407
+
408
+ This library is primarily designed to prevent **Cross-Site Scripting (XSS)** and unauthorized protocol execution via malicious URLs.
409
+
410
+ * **Direct XSS Execution:** Blocks `javascript:` and `vbscript:` schemes outright, even if they are obfuscated with whitespaces or control characters.
411
+ * **Nested XSS in Data/Blob URLs:** Deeply inspects and sanitizes payloads within `data:` and `blob:` URLs. If an attacker tries to hide malicious HTML/SVG within a Base64-encoded data URL, the embedded content is purified using DOMPurify and re-encoded back into a sanitized URL.
412
+ * **Unauthorized Schemes:** Denies unknown or unregistered URI schemes by default, preventing application-specific or OS-level protocol hijacking (unless explicitly allowed).
413
+
414
+ ### Out-of-Scope (What we DO NOT protect against)
415
+
416
+ This library validates the *syntax and safe construction* of a URL, but it does not validate the *destination or intent* of the server it points to, nor does it secure a compromised host environment.
417
+
418
+ * **Server-Side Request Forgery (SSRF):** We do not check if a URL points to an internal IP address (e.g., `http://localhost`, `http://169.254.169.254`) or a malicious external server.
419
+ * **Phishing & Open Redirects:** A perfectly valid HTTP URL pointing to a phishing site (e.g., `https://evil-example.com/login`) will be considered safe. You must implement your own domain allow-listing if you need to restrict destinations.
420
+ * **General HTML Sanitization:** While we sanitize HTML/SVG *inside* data URLs, this library is not a general-purpose HTML body sanitizer. Do not use it to sanitize arbitrary user-generated DOM content.
421
+ * **Compromised Host Environment:** We assume the native `URL` API is intact. We do not protect against runtime attacks where the global `URL` object has been monkey-patched or tampered with. Additionally, when using the lightweight build (`url-sanitizer-wo-dompurify.min.js`), ensuring a secure and untampered global `DOMPurify` instance is the responsibility of the host environment.
422
+
423
+ ## AI / LLM Application Security
424
+
425
+ With the rise of AI agents, RAG (Retrieval-Augmented Generation), and LLM-driven applications, developers increasingly render content generated directly by AI.
426
+ Unpredictable AI outputs introduce unique security risks, and `url-sanitizer` is architected to serve as a reliable defense layer for these modern environments.
427
+
428
+ ### Defending Against Complex & Obfuscated Payloads
429
+ LLMs can generate highly complex, nested, or Base64-encoded `data:` URLs — either through prompt injection, RAG data poisoning, or simply reproducing obfuscated code from their training data.
430
+ Because `url-sanitizer` performs **Deep Data URL Inspection** — decoding the payload, purifying the inner HTML/SVG tree via DOMPurify, and re-encoding it — it physically neutralizes hidden XSS vectors, ensuring that AI-generated data URLs are safe to render.
431
+
432
+ ### Neutralizing Hallucinated Schemes
433
+ LLMs generate URLs based on statistical linguistic patterns rather than factual databases.
434
+ As a result, they frequently hallucinate plausible-looking but non-existent or hazardous URI schemes (e.g., `ai-agent://`, `host-settings:`).
435
+ By operating on a **Secure by Default** whitelist approach, `url-sanitizer` automatically denies any unrecognized or unregistered protocols.
436
+ This strict blocking prevents hallucinated schemes from inadvertently triggering application-specific or OS-level protocol hijacking.
345
437
 
346
438
  ## Performance
347
439
 
@@ -350,10 +442,10 @@ Execution times were measured using [mitata](https://github.com/evanwashere/mita
350
442
  ### Benchmark Results
351
443
 
352
444
  | URL Type | `url-sanitizer` | [@braintree/sanitize-url](https://www.npmjs.com/package/@braintree/sanitize-url) | [strict-url-sanitise](https://www.npmjs.com/package/strict-url-sanitise) |
353
- | :--- | :--- | :--- | :--- |
354
- | **Normal HTTP URL** | ~2.90 µs/iter | ~4.55 µs/iter | ~4.17 µs/iter |
355
- | **XSS URL** | ~1.31 µs/iter | ~1.58 µs/iter | ~10.19 µs/iter |
356
- | **Complex Data URL** | ~263.16 µs/iter | ~2.76 µs/iter | ~9.76 µs/iter |
445
+ | :--- | :---: | :---: | :---: |
446
+ | **Normal HTTP URL** | ~0.94 µs/iter | ~4.32 µs/iter | ~4.02 µs/iter |
447
+ | **XSS URL** | ~5.28 µs/iter | ~1.52 µs/iter | ~9.44 µs/iter |
448
+ | **Complex Data URL** | ~169.41 µs/iter | ~2.65 µs/iter | ~9.46 µs/iter |
357
449
 
358
450
  ### Characteristics & Trade-offs
359
451
 
@@ -1,4 +1,4 @@
1
- var Q={sanitize:(...t)=>{if(globalThis?.DOMPurify)return globalThis.DOMPurify.sanitize(...t);let e=["DOMPurify is not available.","Ensure DOMPurify is exposed globally (e.g., window.DOMPurify)."];throw new Error(e.join(" "))}};var K=/^[\w+/=-]+$/;var k=/data:[\w#&+./;=-]*,/,B=/data:[\w#&+./;=^]*base64,[\w+/=-]+/i,V=/data:[\w#&+./;=-]*,[^\x22]+/g,J=/&#(x(?:00)?[\dA-F]{2}|0?\d{1,3});?/gi,W=/^(?:application\/(?:[\w#&.;-]+\+)?x|image\/svg\+x|text\/(?:ht|x))ml;?/;var ee=/^[a-z][\da-z+.-]*$/,te=/^(?:ext|web)\+[a-z]+$/,F=/(?:java|vb)script/,M=/(?:java|vb)script|blob/,N=/%(?:22|27|3C|3E)|[\x22\x27<>]/,se=/^%[\dA-F]{2}$/i;var y=t=>Object.prototype.toString.call(t).slice(8,-1),f=t=>typeof t=="string"||t instanceof String;var{FileReader:re}=globalThis;var O=[7,8,9,10,11,12,13,27,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255];var G=["aaa","aaas","about","acap","acct","acd","acr","adiumxtra","adt","afp","afs","agtp","aim","amss","android","appdata","apt","ar","ari","ark","at","attachment","aw","barion","beshare","bitcoin","bitcoincash","bl","blob","bluetooth","bolo","brid","browserext","cabal","caip","calculator","callto","cap","cast","casts","chrome","chrome-extension","cid","coap","coaps","com-eventbrite-attendee","content","content-type","crid","cstr","cttps","cvs","dab","dat","data","dav","dhttp","diaspora","dict","did","dilithium3","dis","dlna-playcontainer","dlna-playsingle","dnp","dns","dntp","doi","donau","dpp","drm","dtmi","dtn","dvb","dvx","dweb","ed2k","eid","elsi","embedded","ens","esim","ethereum","example","facetime","feed","feedready","fido","file","finger","first-run-pen-experience","fish","fm","ftp","fuchsia-pkg","geo","gg","git","gitoid","gizmoproject","go","gopher","graph","gtalk","h323","ham","hcap","hcp","hs20","http","https","hxxp","hxxps","hydrazone","hyper","i0","iax","ibi","ibi-","icap","icon","ilstring","im","imap","info","iotdisco","ipfs","ipn","ipns","ipp","ipps","irc","irc6","ircs","iris","iris.beep","iris.lwz","iris.xpc","iris.xpcs","isostore","itms","jabber","jar","jms","keyparc","lastfm","lbry","ldap","ldaps","leaptofrogans","lid","linkid","lorawan","lpa","lvlt","machineProvisioningProgressReporter","magnet","mailto","maps","market","matrix","message","microsoft.windows.camera","microsoft.windows.camera.multipicker","microsoft.windows.camera.picker","mid","mms","mongodb","moz","moz-extension","mqtt","mqtts","ms-access","ms-appinstaller","ms-browser-extension","ms-calculator","ms-drive-to","ms-enrollment","ms-excel","ms-eyecontrolspeech","ms-gamebarservices","ms-gamingoverlay","ms-getoffice","ms-help","ms-infopath","ms-inputapp","ms-launchremotedesktop","ms-lockscreencomponent-config","ms-media-stream-id","ms-meetnow","ms-mixedrealitycapture","ms-mobileplans","ms-newsandinterests","ms-officeapp","ms-people","ms-personacard","ms-powerpoint","ms-project","ms-publisher","ms-recall","ms-remotedesktop","ms-remotedesktop-launch","ms-restoretabcompanion","ms-screenclip","ms-screensketch","ms-search","ms-search-repair","ms-secondary-screen-controller","ms-secondary-screen-setup","ms-settings","ms-settings-airplanemode","ms-settings-bluetooth","ms-settings-camera","ms-settings-cellular","ms-settings-cloudstorage","ms-settings-connectabledevices","ms-settings-displays-topology","ms-settings-emailandaccounts","ms-settings-language","ms-settings-location","ms-settings-lock","ms-settings-nfctransactions","ms-settings-notifications","ms-settings-power","ms-settings-privacy","ms-settings-proximity","ms-settings-screenrotation","ms-settings-wifi","ms-settings-workplace","ms-spd","ms-stickers","ms-sttoverlay","ms-transit-to","ms-useractivityset","ms-uup","ms-virtualtouchpad","ms-visio","ms-walk-to","ms-whiteboard","ms-whiteboard-cmd","ms-widgetboard","ms-widgets","ms-word","msnim","msrp","msrps","mss","mt","mtqp","mtrust","mumble","mupdate","mvn","mvrp","mvrps","news","nfs","ni","nih","nntp","notes","num","ocf","oid","onenote","onenote-cmd","opaquelocktoken","openid","openpgp4fpr","otpauth","palm","paparazzi","payto","pkcs11","platform","pop","pres","proxy","psyc","pttp","pwid","qb","query","quic-transport","redis","rediss","reload","res","resource","rmi","rsync","rtmfp","rtmp","rtsp","rtsps","rtspu","sarif","secondlife","secret-token","service","session","sftp","sgn","shc","shelter","sieve","simpleledger","simplex","sip","sips","skype","smb","smp","sms","smtp","snmp","soap.beep","soap.beeps","soldat","spacify","spiffe","spotify","ssb","ssh","starknet","steam","stun","stuns","submit","svn","swh","swid","swidpath","tag","taler","teamspeak","teapot","teapots","tel","teliaeid","telnet","tftp","things","thismessage","tip","tn3270","tool","turn","turns","tv","udp","unreal","upn","urn","ut2004","uuid-in-package","v-event","vemmi","ventrilo","ves","view-source","vnc","vscode","vscode-insiders","vsls","w3","wasm","wasm-js","wcr","web3","webcal","wifi","ws","wss","wtai","wyciwyg","xcompute","xcon","xcon-userid","xfire","xftp","xmlrpc.beep","xmlrpc.beeps","xmpp","xrcp","xri","ymsgr","z39.50r","z39.50s","ztdnaid"];var ie=t=>{if(!f(t))throw new TypeError(`Expected String but got ${y(t)}.`);let e=[];for(let s of t)e.push(`%${s.charCodeAt(0).toString(16).toUpperCase()}`);return e.join("")},ae=t=>{f(t)&&se.test(t)&&(t=t.toUpperCase());let[e,s,i,r,o,n]=["&","#","<",">",'"',"'"].map(ie),a;return t===e?a=`${e}amp;`:t===i?a=`${e}lt;`:t===r?a=`${e}gt;`:t===o?a=`${e}quot;`:t===n?a=`${e}${s}39;`:a=t,a},oe=t=>{if(f(t)){if(!K.test(t))throw new Error(`Invalid base64 data: ${t}`)}else throw new TypeError(`Expected String but got ${y(t)}.`);let e=atob(t),s=Uint8Array.from([...e].map(o=>o.charCodeAt(0))),i=new Set(O),r;return s.every(o=>i.has(o))?r=e.replace(/\s/g,ie):r=t,r},H=(t,e=0)=>{if(!f(t))throw new TypeError(`Expected String but got ${y(t)}.`);if(Number.isInteger(e)){if(e>16)throw new Error("Character references nested too deeply.")}else throw new TypeError(`Expected Number but got ${y(e)}.`);let s=decodeURIComponent(t);if(/&#/.test(s)){let i=new Set(O),r=[...s.matchAll(J)].reverse();for(let o of r){let[n,a]=o,c;if(/^x[\dA-F]+/i.test(a)?c=parseInt(`0${a}`,16):/^\d+/.test(a)&&(c=parseInt(a)),Number.isInteger(c)){let{index:h}=o,[l,g]=[s.substring(0,h),s.substring(h+n.length)];i.has(c)?(s=`${l}${String.fromCharCode(c)}${g}`,(/#x?$/.test(l)||/^#(?:x(?:00)?[2-7]|\d)/.test(g))&&(s=H(s,++e))):c<256&&(s=`${l}${g}`)}}}return s},ne=(t,e=33554432)=>new Promise((s,i)=>{if(Number.isInteger(t?.size)){if(Number.isInteger(e)&&t.size>e)return i(new DOMException(`Blob size (${t.size} bytes) exceeds the maximum allowed size of ${e} bytes.`,"NotReadableError"))}else return s(null);let r=new re;r.addEventListener("error",()=>i(r.error)),r.addEventListener("abort",()=>s(r.result)),r.addEventListener("load",()=>s(r.result)),r.readAsDataURL(t)}),I=class{#e;constructor(){this.#e=new Set(G)}get(){return[...this.#e]}has(e){return this.#e.has(e)}add(e){if(f(e)){if(F.test(e)||!ee.test(e))throw new Error(`Invalid scheme: ${e}`)}else throw new TypeError(`Expected String but got ${y(e)}.`);return this.#e.add(e),[...this.#e]}remove(e){return this.#e.delete(e)}verify(e){let s;if(f(e))try{let{protocol:i}=new URL(e),r=i.replace(/:$/,""),o=r.split("+");s=!F.test(r)&&te.test(r)||o.every(n=>this.#e.has(n))}catch{s=!1}return!!s}reset(){this.#e.clear();for(let e of G)this.#e.add(e)}};var E=(t,e,s)=>{t&&console.warn(`[URLSanitizer Debug] ${e}`,s?s.message:"")},q=class extends I{#e;#t=new Set;constructor(){super(),this.#e=0}replace(e){if(!f(e))throw new TypeError(`Expected String but got ${y(e)}.`);let s=e;if(k.test(s)){let r=[...s.matchAll(V)].reverse();for(let o of r){let[n]=o;B.test(n)&&([n]=B.exec(n)),this.#e++,this.#t.add(n);let a;try{a=this.sanitize(n,{allow:["data"]})}finally{this.#e--,this.#t.delete(n)}let{index:c}=o,[h,l]=[s.substring(0,c),s.substring(c+n.length)];a?s=`${h}${a}${l}`:s=`${h}${l}`}}return s}purify(e){if(!f(e))throw new TypeError(`Expected String but got ${y(e)}.`);let s=e;try{s=decodeURIComponent(e)}catch{}let i=Q.sanitize(s);i&&k.test(i)&&(i=this.replace(i)),i=i.replace(/(?:#|%23)$/,"").replace(/(?<!(?:#|%23).*)(?:\?|%3F)$/,"");try{return encodeURI(i)}catch{return i}}sanitize(e,s){if(this.#e>16)throw new Error("Data URLs nested too deeply.");let{allow:i,deny:r,only:o,allowRelative:n=!1,debug:a=!1}=s??{},c=new Map([["blob",!1],["data",!1],["file",!1],["javascript",!1],["vbscript",!1]]),h=new Set,l=!1;if(Array.isArray(o)&&o.length){let d=super.get();for(let m of d)c.set(m,!1);let p=Object.values(o);for(let m of p)if(f(m)&&(m=m.trim(),!M.test(m))){if(super.has(m))c.set(m,!0);else{try{super.add(m)}catch(U){let T=`Failed to add scheme '${m}' in 'only' list.`;E(a,T,U)}super.has(m)&&(c.set(m,!0),h.add(m))}!l&&c.has(m)&&(l=c.get(m))}}else{if(Array.isArray(i)&&i.length){let d=Object.values(i);for(let p of d)if(f(p)&&(p=p.trim(),!M.test(p)))if(super.has(p))c.set(p,!0);else{try{super.add(p)}catch(m){let U=`Failed to add scheme '${p}' in 'allow' list.`;E(a,U,m)}super.has(p)&&(c.set(p,!0),h.add(p))}}if(Array.isArray(r)&&r.length){let d=Object.values(r);for(let p of d)f(p)&&(p=p.trim(),p&&c.set(p,!1))}}let g,_=super.verify(e),v=!1;if(!_&&n)try{let{hostname:d,protocol:p}=new URL(e,"http://dummy.local");p==="http:"&&d==="dummy.local"&&(_=!0,v=!0)}catch(d){E(a,"Failed to parse relative URL.",d)}if(_){let d,p,m,U,T,A="",z=[],$=!1;if(v)$=!0;else if({hash:d,href:p,pathname:m,protocol:U,search:T}=new URL(e),A=U.replace(/:$/,""),z=A.split("+"),l)$=z.every(u=>c.get(u));else for(let[u,x]of c.entries())if($=x||A!==u&&z.every(S=>S!==u),!$)break;if($){let L=v?!1:z.includes("data"),u=v?e:p;if(L){let[x,...S]=m.split(","),D=`${S.join(",")}${T}${d}`,C=x.split(";"),X=C[C.length-1]==="base64",b=D;X&&(b=oe(D));try{let P=H(b).trim(),{protocol:Z}=new URL(P);Z.replace(/:$/,"").split("+").some(ce=>M.test(ce))&&(u="")}catch(P){E(a,"Failed to parse inner data URL protocol.",P)}let Y=k.test(b);(b!==D||Y)&&Y&&(b=this.replace(b)),(!x||W.test(x))&&(b=this.purify(b)),u&&b?(X&&b!==D&&C.pop(),u=`${A}:${C.join(";")},${b}`):u=""}if(!L&&N.test(u)){let x=N.exec(u),{index:S}=x;u=u.substring(0,S).replace(/[?&]$/,"")}u&&(g=u.replace(/%26/g,ae))}h.size&&h.forEach(L=>{super.remove(L)})}return g||null}parse(e,s){if(!f(e))throw new TypeError(`Expected String but got ${y(e)}.`);let i=new Map([["input",e]]),r;if(this.verify(e)){let{protocol:o}=new URL(e);o==="blob:"?r=e:r=this.sanitize(e,s??{allow:["data","file"]})}if(r){let o=new URL(r),{pathname:n,protocol:a}=o,h=a.replace(/:$/,"").split("+").includes("data");if(i.set("valid",!0),h){let l=new Map,[g,..._]=n.split(","),v=`${_.join(",")}`,d=g.split(";"),p=d[d.length-1]==="base64";p&&d.pop(),l.set("mime",d.join(";")),l.set("base64",p),l.set("data",v),i.set("data",Object.fromEntries(l))}else i.set("data",null);for(let l in o){let g=o[l];f(g)&&i.set(l,g)}}else i.set("valid",!1);return Object.fromEntries(i)}reset(){super.reset(),this.#e=0,this.#t.clear()}};var w=new q,fe=async(t,e={allow:[],deny:[],only:[],allowRelative:!1,debug:!1,maxBlobSize:33554432})=>{let s=!!e?.debug,i=Number.isInteger(e?.maxBlobSize)&&e.maxBlobSize>0?e.maxBlobSize:33554432,r;if(t&&f(t)){let o;try{let{protocol:n}=new URL(t);o=n.replace(/:$/,"")}catch(n){E(s,`Invalid URL input format: ${t}`,n)}if(o==="blob"){let{allow:n,deny:a,only:c}=e;if(Array.isArray(n)&&n.includes("blob")&&!(Array.isArray(a)&&a.includes("blob"))||Array.isArray(c)&&c.includes("blob")){let h;try{h=await fetch(t).then(l=>l.blob()).then(l=>ne(l,i))}catch(l){let g=`Failed to fetch and convert blob URL: ${t}`;E(s,g,l)}if(h){if(Array.isArray(c))c.includes("data")||c.push("data");else if(Array.isArray(n)&&(n.includes("data")||n.push("data"),Array.isArray(a)&&a.includes("data"))){let l=a.indexOf("data");a.splice(l,1)}r=w.sanitize(h,e)}}URL.revokeObjectURL(t)}else(o||e.allowRelative)&&(r=w.sanitize(t,e))}return r||null},he=(t,e)=>{let s;if(t&&f(t)){let i;try{let{protocol:r}=new URL(t);i=r.replace(/:$/,"")}catch(r){let o=`Failed to parse URL: ${t}`;E(e?.debug,o,r)}i==="blob"?URL.revokeObjectURL(t):(i||e?.allowRelative)&&(s=w.sanitize(t,e))}return s||null},ue=async t=>w.parse(t),ge=t=>w.parse(t),be=async t=>w.verify(t),ye=t=>w.verify(t),we=w;export{we as default,be as isURI,ye as isURISync,ue as parseURL,ge as parseURLSync,fe as sanitizeURL,he as sanitizeURLSync};
1
+ var fe=Object.freeze(["addHook","removeHook","removeAllHooks","setConfig"]),O=[],I=new Proxy({},{get(t,e){let s=globalThis.DOMPurify||globalThis.window?.DOMPurify;if(s){if(O.length){for(let{prop:r,args:o}of O)typeof s[r]=="function"&&s[r](...o);O.length=0}let a=s[e];return typeof a=="function"?a.bind(s):a}return(...a)=>{if(fe.includes(e)){O.push({prop:e,args:a});return}throw new Error("DOMPurify is not available. Ensure DOMPurify is exposed globally (e.g., window.DOMPurify).")}}});var R=t=>Object.prototype.toString.call(t).slice(8,-1),m=t=>typeof t=="string"||t instanceof String;var K=["aaa","aaas","about","acap","acct","acd","acr","adiumxtra","adt","afp","afs","agtp","aim","amss","android","appdata","apt","ar","ari","ark","at","attachment","aw","barion","beshare","bitcoin","bitcoincash","bl","blob","bluetooth","bolo","brid","browserext","cabal","caip","calculator","callto","cap","cast","casts","chrome","chrome-extension","cid","coap","coaps","com-eventbrite-attendee","content","content-type","crid","cstr","cttps","cvs","dab","dat","data","dav","dhttp","diaspora","dict","did","dilithium3","dis","dlna-playcontainer","dlna-playsingle","dnp","dns","dntp","doi","donau","dpp","drm","dtmi","dtn","dvb","dvx","dweb","ed2k","eid","elsi","embedded","ens","esim","ethereum","example","facetime","feed","feedready","fido","file","finger","first-run-pen-experience","fish","fm","ftp","fuchsia-pkg","geo","gg","git","gitoid","gizmoproject","go","gopher","graph","gtalk","h323","ham","hcap","hcp","hs20","http","https","hxxp","hxxps","hydrazone","hyper","i0","iax","ibi","ibi-","icap","icon","ilstring","im","imap","info","iotdisco","ipfs","ipn","ipns","ipp","ipps","irc","irc6","ircs","iris","iris.beep","iris.lwz","iris.xpc","iris.xpcs","isostore","itms","jabber","jar","jms","keyparc","lastfm","lbry","ldap","ldaps","leaptofrogans","lid","linkid","lorawan","lpa","lvlt","machineProvisioningProgressReporter","magnet","mailto","maps","market","matrix","message","microsoft.windows.camera","microsoft.windows.camera.multipicker","microsoft.windows.camera.picker","mid","mms","mongodb","moz","moz-extension","mqtt","mqtts","ms-access","ms-appinstaller","ms-browser-extension","ms-calculator","ms-drive-to","ms-enrollment","ms-excel","ms-eyecontrolspeech","ms-gamebarservices","ms-gamingoverlay","ms-getoffice","ms-help","ms-infopath","ms-inputapp","ms-launchremotedesktop","ms-lockscreencomponent-config","ms-media-stream-id","ms-meetnow","ms-mixedrealitycapture","ms-mobileplans","ms-newsandinterests","ms-officeapp","ms-people","ms-personacard","ms-powerpoint","ms-project","ms-publisher","ms-recall","ms-remotedesktop","ms-remotedesktop-launch","ms-restoretabcompanion","ms-screenclip","ms-screensketch","ms-search","ms-search-repair","ms-secondary-screen-controller","ms-secondary-screen-setup","ms-settings","ms-settings-airplanemode","ms-settings-bluetooth","ms-settings-camera","ms-settings-cellular","ms-settings-cloudstorage","ms-settings-connectabledevices","ms-settings-displays-topology","ms-settings-emailandaccounts","ms-settings-language","ms-settings-location","ms-settings-lock","ms-settings-nfctransactions","ms-settings-notifications","ms-settings-power","ms-settings-privacy","ms-settings-proximity","ms-settings-screenrotation","ms-settings-wifi","ms-settings-workplace","ms-spd","ms-stickers","ms-sttoverlay","ms-transit-to","ms-useractivityset","ms-uup","ms-virtualtouchpad","ms-visio","ms-walk-to","ms-whiteboard","ms-whiteboard-cmd","ms-widgetboard","ms-widgets","ms-word","msnim","msrp","msrps","mss","mt","mtqp","mtrust","mumble","mupdate","mvn","mvrp","mvrps","news","nfs","ni","nih","nntp","notes","num","ocf","oid","onenote","onenote-cmd","opaquelocktoken","openid","openpgp4fpr","otpauth","palm","paparazzi","payto","pkcs11","platform","pop","pres","proxy","psyc","pttp","pwid","qb","query","quic-transport","redis","rediss","reload","res","resource","rmi","rsync","rtmfp","rtmp","rtsp","rtsps","rtspu","sarif","secondlife","secret-token","service","session","sftp","sgn","shc","shelter","sieve","simpleledger","simplex","sip","sips","skype","smb","smp","sms","smtp","snmp","soap.beep","soap.beeps","soldat","spacify","spiffe","spotify","ssb","ssh","starknet","steam","stun","stuns","submit","svn","swh","swid","swidpath","tag","taler","teamspeak","teapot","teapots","tel","teliaeid","telnet","tftp","things","thismessage","tip","tn3270","tool","turn","turns","tv","udp","unreal","upn","urn","ut2004","uuid-in-package","v-event","vemmi","ventrilo","ves","view-source","vnc","vscode","vscode-insiders","vsls","w3","wasm","wasm-js","wcr","web3","webcal","wifi","ws","wss","wtai","wyciwyg","xcompute","xcon","xcon-userid","xfire","xftp","xmlrpc.beep","xmlrpc.beeps","xmpp","xrcp","xri","ymsgr","z39.50r","z39.50s","ztdnaid"];var{FileReader:J}=globalThis;var ee=/(?:#|%23)$/,te=/^(?:application\/(?:[\w#&.;-]+\+)?x|image\/svg\+x|text\/(?:ht|x))ml;?/;var se=/(?<!(?:#|%23).*)(?:\?|%3F)$/,G=/^[a-z][\da-z+.-]*$/,re=/^(?:web\+[a-z]+|ext\+[a-z][\da-z.-]*)$/,C=/^(?:java|vb)script$/i,H=/^(?:(?:java|vb)script|blob)$/i,z=/["'<>]|%(?:22|27|3C|3E)|&(?:apos|lt|gt|quot|#0*(?:34|39|60|62)|#x0*(?:22|27|3C|3E));/i,ae=/^%[\dA-F]{2}$/i,oe=/^(?:\/{2,}|\\|[a-z][a-z\d+\-.]*:[^/])/i;var ne=[7,8,9,10,11,12,13,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255];var B=new Set(ne),P=new Map([[128,8364],[130,8218],[131,402],[132,8222],[133,8230],[134,8224],[135,8225],[136,710],[137,8240],[138,352],[139,8249],[140,338],[142,381],[145,8216],[146,8217],[147,8220],[148,8221],[149,8226],[150,8211],[151,8212],[152,732],[153,8482],[154,353],[155,8250],[156,339],[158,382],[159,376]]),be=()=>{let t=new Map;for(let e=0;e<256;e++)!B.has(e)&&!P.has(e)&&t.set(e,`\\x${e.toString(16).padStart(2,"0").toUpperCase()}`);return t},F=be();var[v,Re,xe,Ee,ve,Se]=["&","#","<",">",'"',"'"].map(t=>`%${t.charCodeAt(0).toString(16).toUpperCase()}`),Ue=new RegExp(`[${[...F.values()].join("")}]`),_e=/&#(x(?:00)?[\dA-F]{2}|0?\d{1,3});?/gi,M=class{#e=new Set(K);get(){return[...this.#e]}has(e){return this.#e.has(e)}verify(e,s=this.#e){if(!m(e))return!1;try{let{protocol:a}=new URL(e),r=a.replace(/:$/,""),o=r.split("+");return o.some(l=>C.test(l))?!1:re.test(r)||o.every(l=>s.has(l))}catch{return!1}}};var ie=t=>{let e=t;if(m(e))switch(ae.test(e)&&(e=e.toUpperCase()),e){case v:return`${v}amp;`;case xe:return`${v}lt;`;case Ee:return`${v}gt;`;case ve:return`${v}quot;`;case Se:return`${v}${Re}39;`;default:}return e},ce=t=>{if(!m(t))throw new TypeError(`Expected String but got ${R(t)}.`);let e=t.replace(/\s/g,""),s;try{s=atob(e)}catch{throw new Error(`Invalid base64 data: ${t}`)}let a=Uint8Array.from(s,r=>r.charCodeAt(0));try{let o=new TextDecoder("utf-8",{fatal:!0}).decode(a);return Ue.test(o)?e:o}catch{return e}},Te=(t,e)=>{let s=/x/i.test(e[0])?parseInt(e.substring(1),16):parseInt(e,10);if(Number.isInteger(s)){if(B.has(s))return String.fromCharCode(s);if(P.has(s)){let a=P.get(s);return String.fromCodePoint(a)}else if(F.has(s))return""}return t},le=(t,e=0)=>{if(!m(t))throw new TypeError(`Expected String but got ${R(t)}.`);if(!Number.isInteger(e))throw new TypeError(`Expected Number but got ${R(e)}.`);let s=decodeURIComponent(t);for(;/&#/.test(s);){if(e>16)throw new Error("Character references nested too deeply.");let a=s;if(s=s.replace(_e,Te),s===a)break;e++}return s},pe=(t,e=16777216)=>new Promise((s,a)=>{if(Number.isInteger(t?.size)){if(Number.isInteger(e)&&t.size>e){let o=`Blob size (${t.size} bytes) exceeds max (${e} bytes).`;return a(new DOMException(o,"NotReadableError"))}}else return s(null);let r=new J;r.addEventListener("error",()=>a(r.error)),r.addEventListener("abort",()=>s(r.result)),r.addEventListener("load",()=>s(r.result)),r.readAsDataURL(t)}),me=t=>m(t)?t.replace(ee,"").replace(se,""):t;var Ce=Object.freeze(["href","origin","protocol","username","password","host","hostname","port","pathname","search","hash"]),Le=Object.freeze({RETURN_DOM:!1,RETURN_DOM_FRAGMENT:!1,RETURN_TRUSTED_TYPE:!1}),h=(t,e,s)=>{t&&console.warn(`[URLSanitizer Debug] ${e}`,s?s.message:"")},V=class t extends M{#e;static#t=null;static#s=null;static#o(e,s){if(!s.attrValue||!/^\s*data:/i.test(s.attrValue))return;let a=t.#s,r=t.#t;if(!r||!a)return;let o;try{o=new URL(s.attrValue)}catch{return}if(o.protocol==="data:"){let n=s.attrValue;if(r.recurse||(r.recurse=new Set),r.recurse.has(n)){let l=`Circular Data URL detected and skipped: ${n}`;h(r.debug,l),s.attrValue="";return}r.nest++,r.recurse.add(n);try{let l=a.#a(n,{allow:["data"],deny:[],only:[],allowRelative:!1},r);s.attrValue=l||""}finally{r.nest--,r.recurse.delete(n)}}}static{I.setConfig(Le),I.addHook("uponSanitizeAttribute",t.#o)}constructor(){super(),this.#e=new Set(super.get())}#r(e,s,a,r,o){if(H.test(e))return!1;if(e.split("+").some(d=>C.test(d))||!G.test(e)){let d=`Failed to add scheme '${e}' in '${s}' list.`;return h(o.debug,d),!1}return r.set(e,!0),a.add(e),!0}#n(e,s){let a=e;try{a=decodeURIComponent(e)}catch{}let r=t.#s,o=t.#t;t.#s=this,t.#t=s;let n;try{n=s.domPurify.sanitize(a)}finally{t.#s=r,t.#t=o}n=me(n);try{return encodeURI(n)}catch{return n}}#a(e,s,a){if(a.nest>16)throw new Error("Data URLs nested too deeply.");let{allow:r,deny:o,only:n,allowRelative:l}=s,d=this.#e,u=!1,c=new Map(a.schemeMap);if(n.length){d=new Set;for(let i of n)m(i)&&(i=i.trim(),this.#r(i,"only",d,c,a)&&!u&&(u=!0))}else{if(r.length){d=new Set(this.#e);for(let i of r)m(i)&&this.#r(i.trim(),"allow",d,c,a)}if(o.length)for(let i of o)m(i)&&(i=i.trim(),i&&c.set(i,!1))}let f,S=super.verify(e,d),x=!1,g="";if(!S&&l&&!oe.test(e))try{let i=new URL(e,"http://dummy.local");i.protocol==="http:"&&i.hostname==="dummy.local"&&(S=!0,x=!0,g=`${i.pathname}${i.search}${i.hash}`)}catch(i){h(a.debug,"Failed to parse relative URL.",i)}if(S){let i,D,Y,q,Q,L="",A=[],U=!1;if(x)U=!0;else if({hash:i,href:D,pathname:Y,protocol:q,search:Q}=new URL(e),L=q.replace(/:$/,""),A=L.split("+"),u)U=A.every(p=>c.get(p));else for(let[p,b]of c.entries())if(U=b||L!==p&&A.every(_=>_!==p),!U)break;if(U){let N=x?!1:A.includes("data"),p=x?g:D;if(N){let[b,..._]=Y.split(","),j=`${_.join(",")}${Q}${i}`,$=b.split(";"),W=$[$.length-1]==="base64",w=j;if(W)try{w=ce(j)}catch(T){h(a.debug,"Failed to parse base64 data.",T),p=""}try{let T=le(w).trim(),{protocol:Z}=new URL(T,"http://dummy.local");Z.replace(/:$/,"").split("+").some(de=>H.test(de))&&(p="")}catch(T){h(a.debug,"Failed to parse inner data URL protocol.",T)}(!b||te.test(b))&&(w=this.#n(w,a)),p&&w?(W&&w!==j&&$.pop(),p=`${L}:${$.join(";")},${w}`):p=""}if(!N&&z.test(p)){let b=z.exec(p),{index:_}=b;p=p.substring(0,_).replace(/[?&]$/,"")}p&&(f=p)}}return f||null}sanitize(e,s){if(!e||!m(e))return null;if(!(s&&(Array.isArray(s.deny)&&s.deny.length>0||Array.isArray(s.only)&&s.only.length>0||s.allowRelative))&&(e.startsWith("https://")||e.startsWith("http://"))&&!z.test(e)&&!e.includes("data:"))try{let{href:n}=new URL(e);return n.replace(/%26/g,ie)}catch{return null}let r={allow:Array.isArray(s?.allow)?s.allow:[],deny:Array.isArray(s?.deny)?s.deny:[],only:Array.isArray(s?.only)?s.only:[],allowRelative:!!s?.allowRelative},o={debug:!!s?.debug,domPurify:I,nest:0,recurse:null,schemeMap:new Map([["blob",!1],["data",!1],["file",!1],["javascript",!1],["vbscript",!1]])};return this.#a(e,r,o)}inspect(e,s){if(!m(e))throw new TypeError(`Expected String but got ${R(e)}.`);let a=new Map([["input",e]]),r;if(this.verify(e)){let{protocol:o}=new URL(e);o==="blob:"?r=e:r=this.sanitize(e,s??{allow:["data","file"]})}if(r){let o=new URL(r),{pathname:n,protocol:l}=o,u=l.replace(/:$/,"").split("+").includes("data");if(a.set("valid",!0),u){let c=new Map,[f,...S]=n.split(","),x=`${S.join(",")}${o.search}${o.hash}`,g=f.split(";"),i=g[g.length-1]==="base64";i&&g.pop(),c.set("mime",g.join(";")),c.set("base64",i),c.set("data",x),a.set("data",Object.fromEntries(c))}else a.set("data",null);for(let c of Ce){let f=o[c];m(f)&&a.set(c,f)}}else a.set("valid",!1);return Object.fromEntries(a)}get(){return[...this.#e]}has(e){return this.#e.has(e)}add(e){if(!m(e))throw new TypeError(`Expected String but got ${R(e)}.`);if(e.split("+").some(r=>C.test(r))||!G.test(e))throw new Error(`Invalid scheme: ${e}`);return this.#e.add(e),[...this.#e]}remove(e){return this.#e.delete(e)}reset(){this.#e=new Set(super.get())}verify(e,s=this.#e){return super.verify(e,s)}};var y=new V,Ae=async(t,e={allow:[],deny:[],only:[],allowRelative:!1,debug:!1,revokeObjectURL:!1,maxBlobSize:16777216})=>{let s=!!e?.debug,a=Number.isInteger(e?.maxBlobSize)&&e.maxBlobSize>0?e.maxBlobSize:16777216,r;if(t&&m(t)){let o;try{let{protocol:n}=new URL(t);o=n.replace(/:$/,"")}catch(n){h(s,`Invalid URL input format: ${t}`,n)}if(o==="blob"){let{allow:n,deny:l,only:d}=e;if(Array.isArray(n)&&n.includes("blob")&&!(Array.isArray(l)&&l.includes("blob"))||Array.isArray(d)&&d.includes("blob")){let u;try{u=await fetch(t).then(c=>c.blob()).then(c=>pe(c,a))}catch(c){let f=`Failed to fetch and convert blob URL: ${t}`;h(s,f,c)}if(u){let c={...e};Array.isArray(e.only)?c.only=e.only.includes("data")?e.only:[...e.only,"data"]:Array.isArray(e.allow)&&(c.allow=e.allow.includes("data")?e.allow:[...e.allow,"data"],Array.isArray(e.deny)&&(c.deny=e.deny.filter(f=>f!=="data"))),r=y.sanitize(u,c)}}e?.revokeObjectURL&&URL.revokeObjectURL(t)}else(o||e.allowRelative)&&(r=y.sanitize(t,e))}return r||null},$e=(t,e={allow:[],deny:[],only:[],allowRelative:!1,debug:!1,revokeObjectURL:!1})=>{let s=!!e?.debug,a;if(t&&m(t)){let r;try{let{protocol:o}=new URL(t);r=o.replace(/:$/,"")}catch(o){let n=`Invalid URL input format: ${t}`;h(s,n,o)}if(r==="blob")return e?.revokeObjectURL&&URL.revokeObjectURL(t),null;(r||e.allowRelative)&&(a=y.sanitize(t,e))}return a||null},Oe=async t=>y.inspect(t),Ie=t=>Oe(t),ze=t=>y.inspect(t),Pe=t=>ze(t),Me=async t=>y.verify(t),ke=t=>y.verify(t),De=y;export{De as default,Me as isURI,ke as isURISync,Ie as parseURL,Pe as parseURLSync,Ae as sanitizeURL,$e as sanitizeURLSync};
2
2
  /*!
3
3
  * URL Sanitizer
4
4
  *