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 +166 -74
- package/dist/url-sanitizer-wo-dompurify.min.js +1 -1
- package/dist/url-sanitizer-wo-dompurify.min.js.map +4 -4
- package/dist/url-sanitizer.min.js +4 -4
- package/dist/url-sanitizer.min.js.map +4 -4
- package/package.json +16 -14
- package/src/lib/file/text-chars.json +1 -1
- package/src/mjs/common.js +7 -6
- package/src/mjs/constant.js +39 -19
- package/src/mjs/dompurify.js +6 -0
- package/src/mjs/file-reader.js +125 -68
- package/src/mjs/regexp.js +79 -0
- package/src/mjs/sanitizer.js +416 -249
- package/src/mjs/text-chars.js +68 -0
- package/src/mjs/uri-util.js +176 -172
- package/types/mjs/common.d.ts +2 -2
- package/types/mjs/constant.d.ts +5 -17
- package/types/mjs/dompurify.d.ts +1 -1
- package/types/mjs/file-reader.d.ts +10 -10
- package/types/mjs/regexp.d.ts +12 -0
- package/types/mjs/sanitizer.d.ts +26 -15
- package/types/mjs/text-chars.d.ts +3 -0
- package/types/mjs/uri-util.d.ts +9 -10
- package/src/mjs/lib-util.js +0 -37
- package/types/mjs/lib-util.d.ts +0 -3
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
|
|
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
|
-
* [
|
|
28
|
-
* [
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
*
|
|
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 **
|
|
82
|
-
* opt.allow
|
|
83
|
-
* opt.deny
|
|
84
|
-
* opt.only
|
|
107
|
+
* opt **\[object\]** Options.
|
|
108
|
+
* opt.allow **\[Array<string>\]** Array of allowed schemes, e.g., \['data'\].
|
|
109
|
+
* opt.deny **\[Array<string>\]** Array of denied schemes, e.g., \['web+foo'\].
|
|
110
|
+
* opt.only **\[Array<string>\]** Array of specific schemes to allow, e.g., \['git', 'https'\].
|
|
85
111
|
`only` takes precedence over `allow` and `deny`.
|
|
86
|
-
* opt.allowRelative **
|
|
87
|
-
* opt.debug **
|
|
88
|
-
* opt.
|
|
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
|
|
117
|
+
**Returns** **Promise<string?>** 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
|
|
138
|
+
const res2 = await sanitizeURL('https://example.com/?<script>alert(1)</script>');
|
|
97
139
|
// => 'https://example.com/'
|
|
98
140
|
|
|
99
|
-
const
|
|
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
|
|
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(
|
|
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
|
|
115
|
-
const
|
|
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(
|
|
162
|
+
console.log(decodeURIComponent(res4));
|
|
121
163
|
// => 'data:text/html,<div></div>'
|
|
122
164
|
|
|
123
|
-
const
|
|
124
|
-
const
|
|
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(
|
|
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
|
|
175
|
+
const blob5 = new Blob(['<svg><g onload="alert(1)"/></svg>'], {
|
|
134
176
|
type: 'image/svg+xml'
|
|
135
177
|
});
|
|
136
|
-
const
|
|
137
|
-
const
|
|
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(
|
|
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
|
|
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
|
|
198
|
+
const res7 = await sanitizeURL('http://example.com', {
|
|
157
199
|
only: ['data', 'git', 'https']
|
|
158
200
|
});
|
|
159
201
|
// => null
|
|
160
202
|
|
|
161
|
-
const
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
227
|
+
**Note:** `parseURL()` is deprecated. Please use `inspectURL()` instead.
|
|
181
228
|
|
|
182
|
-
|
|
229
|
+
Inspects, parses, and sanitizes the given URL asynchronously.
|
|
183
230
|
|
|
184
|
-
*
|
|
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
|
|
239
|
+
**Returns** **Promise<InspectedURLResult>** Result.
|
|
191
240
|
|
|
192
|
-
####
|
|
241
|
+
#### InspectedURLResult
|
|
193
242
|
|
|
194
|
-
An object
|
|
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**
|
|
198
|
-
* `data` **
|
|
199
|
-
* `data.mime` **string
|
|
200
|
-
* `data.base64` **boolean
|
|
201
|
-
* `data.data` **string
|
|
202
|
-
* `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, `hash` — **string
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
###
|
|
328
|
+
### inspectURLSync(url)
|
|
279
329
|
|
|
280
|
-
|
|
330
|
+
**Note:** `parseURLSync()` is deprecated. Please use `inspectURLSync()` instead.
|
|
331
|
+
|
|
332
|
+
Synchronous version of `inspectURL()`.
|
|
281
333
|
|
|
282
334
|
### isURI(uri)
|
|
283
335
|
|
|
284
|
-
|
|
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
|
|
346
|
+
**Returns** **Promise<boolean>** `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
|
|
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** | ~
|
|
355
|
-
| **XSS URL** | ~
|
|
356
|
-
| **Complex Data URL** | ~
|
|
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
|
*
|