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.
@@ -6,41 +6,70 @@
6
6
  import { domPurify } from './dompurify.js';
7
7
  import { getType, isString } from './common.js';
8
8
  import {
9
- createDataURLFromBlob, escapeURLEncodedHTMLChars, parseBase64,
10
- parseURLEncodedNumCharRef, URISchemes
9
+ URISchemes, createDataURLFromBlob, escapeURLEncodedHTMLChars, parseBase64,
10
+ parseURLEncodedNumCharRef, trimTrailingEmptyQueryAndHash
11
11
  } from './uri-util.js';
12
+
13
+ /* constants */
14
+ import { MAX_BLOB_SIZE, MAX_NEST } from './constant.js';
12
15
  import {
13
- HEX, MAX_BLOB_SIZE, REG_DATA_URL, REG_DATA_URL_B64, REG_DATA_URL_G,
14
- REG_MIME_DOM, REG_SCRIPT_BLOB, REG_TAG_QUOT
15
- } from './constant.js';
16
+ REG_MIME_DOM, REG_SCHEME, REG_SCRIPT, REG_SCRIPT_BLOB, REG_TAG_QUOT,
17
+ REG_VERIFY_RELATIVE
18
+ } from './regexp.js';
19
+ const URL_PROPS = Object.freeze([
20
+ 'href',
21
+ 'origin',
22
+ 'protocol',
23
+ 'username',
24
+ 'password',
25
+ 'host',
26
+ 'hostname',
27
+ 'port',
28
+ 'pathname',
29
+ 'search',
30
+ 'hash'
31
+ ]);
32
+ const INTERNAL_PURIFY_CONFIG = Object.freeze({
33
+ RETURN_DOM: false,
34
+ RETURN_DOM_FRAGMENT: false,
35
+ RETURN_TRUSTED_TYPE: false
36
+ });
16
37
 
17
38
  /* typedef */
18
39
  /**
19
- * @typedef {object} ParsedURL - object with additional props based on URL API
20
- * @property {string} input - URL input
21
- * @property {boolean} valid - is valid URI
22
- * @property {object} [data] - parsed result of data URL, `null`able
23
- * @property {string} [data.mime] - MIME type
24
- * @property {boolean} [data.base64] - is base64 encoded
25
- * @property {string} [data.data] - data part of the data URL
26
- * @property {string} [href] - sanitized URL input
27
- * @property {string} [origin] - scheme, domain and port of the sanitized URL
28
- * @property {string} [protocol] - protocol scheme of the sanitized URL
29
- * @property {string} [username] - username specified before the domain name
30
- * @property {string} [password] - password specified before the domain name
31
- * @property {string} [host] - domain and port of the sanitized URL
32
- * @property {string} [hostname] - domain of the sanitized URL
33
- * @property {string} [port] - port number of the sanitized URL
34
- * @property {string} [pathname] - path of the sanitized URL
35
- * @property {string} [search] - query string of the sanitized URL
36
- * @property {string} [hash] - fragment identifier of the sanitized URL
40
+ * The parsed result of a data URL.
41
+ * @typedef {object} InspectedDataURL
42
+ * @property {string} mime - The MIME type of the data.
43
+ * @property {boolean} base64 - Indicates whether the data is base64-encoded.
44
+ * @property {string} data - The actual data part of the data URL.
45
+ */
46
+
47
+ /**
48
+ * The result of an inspected URL, extending the standard URL API.
49
+ * The properties except for input and valid are omitted from the object for invalid URLs.
50
+ * @typedef {object} InspectedURLResult
51
+ * @property {string} input - The original URL input.
52
+ * @property {boolean} valid - Indicates whether the URI is valid.
53
+ * @property {InspectedDataURL | null} [data] - The parsed result of a data URL. Null if not a data URL.
54
+ * @property {string} [href] - The sanitized URL input.
55
+ * @property {string} [origin] - The scheme, domain, and port.
56
+ * @property {string} [protocol] - The protocol scheme.
57
+ * @property {string} [username] - The specified username.
58
+ * @property {string} [password] - The specified password.
59
+ * @property {string} [host] - The domain and port.
60
+ * @property {string} [hostname] - The domain.
61
+ * @property {string} [port] - The port number.
62
+ * @property {string} [pathname] - The path.
63
+ * @property {string} [search] - The query string.
64
+ * @property {string} [hash] - The fragment identifier.
37
65
  */
38
66
 
39
67
  /**
40
68
  * Internal debug logger.
41
- * @param {boolean} isDebug - Debug flag.
42
- * @param {string} message - Output message.
43
- * @param {Error} [error] - Caught error object.
69
+ * @param {boolean} isDebug - Flag to enable or disable debug output.
70
+ * @param {string} message - The message to output.
71
+ * @param {Error} [error] - The caught error object, if any.
72
+ * @returns {void}
44
73
  */
45
74
  export const logDebug = (isDebug, message, error) => {
46
75
  if (isDebug) {
@@ -53,180 +82,181 @@ export const logDebug = (isDebug, message, error) => {
53
82
  */
54
83
  class URLSanitizer extends URISchemes {
55
84
  /* private fields */
56
- #nest;
57
- #recurse = new Set();
85
+ #allowedSchemes;
86
+
87
+ static #currentCtx = null;
88
+
89
+ /** @type {URLSanitizer | null} */
90
+ static #currentInstance = null;
58
91
 
59
92
  /**
60
- * construct
93
+ * DOMPurify 'uponSanitizeAttribute' hook callback.
94
+ * @private
95
+ * @static
96
+ * @param {Node} node - The DOM node being sanitized.
97
+ * @param {object} e - The event object containing attribute details.
61
98
  */
99
+ static #uponSanitizeAttribute(node, e) {
100
+ if (!e.attrValue || !/^\s*data:/i.test(e.attrValue)) {
101
+ return;
102
+ }
103
+ /** @type {URLSanitizer | null} */
104
+ const sanitizer = URLSanitizer.#currentInstance;
105
+ const ctx = URLSanitizer.#currentCtx;
106
+ if (!ctx || !sanitizer) {
107
+ return;
108
+ }
109
+ let urlObj;
110
+ try {
111
+ urlObj = new URL(e.attrValue);
112
+ } catch {
113
+ return;
114
+ }
115
+ if (urlObj.protocol === 'data:') {
116
+ const originalUrl = e.attrValue;
117
+ if (!ctx.recurse) {
118
+ ctx.recurse = new Set();
119
+ }
120
+ if (ctx.recurse.has(originalUrl)) {
121
+ const msg = `Circular Data URL detected and skipped: ${originalUrl}`;
122
+ logDebug(ctx.debug, msg);
123
+ e.attrValue = '';
124
+ return;
125
+ }
126
+ ctx.nest++;
127
+ ctx.recurse.add(originalUrl);
128
+ try {
129
+ const sanitized = sanitizer.#process(originalUrl, {
130
+ allow: ['data'],
131
+ deny: [],
132
+ only: [],
133
+ allowRelative: false
134
+ }, ctx);
135
+ e.attrValue = sanitized || '';
136
+ } finally {
137
+ ctx.nest--;
138
+ ctx.recurse.delete(originalUrl);
139
+ }
140
+ }
141
+ }
142
+
143
+ static {
144
+ domPurify.setConfig(INTERNAL_PURIFY_CONFIG);
145
+ domPurify.addHook(
146
+ 'uponSanitizeAttribute',
147
+ URLSanitizer.#uponSanitizeAttribute
148
+ );
149
+ }
150
+
62
151
  constructor() {
63
152
  super();
64
- this.#nest = 0;
153
+ this.#allowedSchemes = new Set(super.get());
65
154
  }
66
155
 
67
156
  /**
68
- * replace matched data URLs
69
- * @param {string} data - data URL
70
- * @returns {string} - replaced data URL
157
+ * Helper method to register schemes for the 'allow' or 'only' options.
158
+ * @private
159
+ * @param {string} item - The scheme to register.
160
+ * @param {string} listName - The name of the target option list.
161
+ * @param {Set<string>} allowedSchemes - The local set of allowed schemes.
162
+ * @param {Map<string, boolean>} schemeMap - The local map of schemes.
163
+ * @param {object} ctx - The context for state management.
164
+ * @returns {boolean} True if the scheme is successfully registered.
71
165
  */
72
- replace(data) {
73
- if (!isString(data)) {
74
- throw new TypeError(`Expected String but got ${getType(data)}.`);
166
+ #registerScheme(item, listName, allowedSchemes, schemeMap, ctx) {
167
+ if (REG_SCRIPT_BLOB.test(item)) {
168
+ return false;
75
169
  }
76
- let replacedData = data;
77
- if (REG_DATA_URL.test(replacedData)) {
78
- const matchedDataUrls = replacedData.matchAll(REG_DATA_URL_G);
79
- const items = [...matchedDataUrls].reverse();
80
- for (const item of items) {
81
- let [dataUrl] = item;
82
- if (REG_DATA_URL_B64.test(dataUrl)) {
83
- [dataUrl] = REG_DATA_URL_B64.exec(dataUrl);
84
- }
85
- this.#nest++;
86
- this.#recurse.add(dataUrl);
87
- let parsedDataUrl;
88
- try {
89
- parsedDataUrl = this.sanitize(dataUrl, {
90
- allow: ['data']
91
- });
92
- } finally {
93
- this.#nest--;
94
- this.#recurse.delete(dataUrl);
95
- }
96
- const { index } = item;
97
- const [preDataUrl, postDataUrl] = [
98
- replacedData.substring(0, index),
99
- replacedData.substring(index + dataUrl.length)
100
- ];
101
- if (parsedDataUrl) {
102
- replacedData = `${preDataUrl}${parsedDataUrl}${postDataUrl}`;
103
- } else {
104
- replacedData = `${preDataUrl}${postDataUrl}`;
105
- }
106
- }
170
+ const schemeParts = item.split('+');
171
+ const isScript = schemeParts.some(s => REG_SCRIPT.test(s));
172
+ if (isScript || !REG_SCHEME.test(item)) {
173
+ const msg = `Failed to add scheme '${item}' in '${listName}' list.`;
174
+ logDebug(ctx.debug, msg);
175
+ return false;
107
176
  }
108
- return replacedData;
177
+ schemeMap.set(item, true);
178
+ allowedSchemes.add(item);
179
+ return true;
109
180
  }
110
181
 
111
182
  /**
112
- * purify URL encoded DOM
113
- * @param {string} dom - DOM string
114
- * @returns {string} - purified DOM string
183
+ * Purifies a URL-encoded DOM string to prevent XSS.
184
+ * @private
185
+ * @param {string} dom - The URL-encoded DOM string.
186
+ * @param {object} ctx - The context for state management.
187
+ * @returns {string} The purified DOM string.
115
188
  */
116
- purify(dom) {
117
- if (!isString(dom)) {
118
- throw new TypeError(`Expected String but got ${getType(dom)}.`);
119
- }
189
+ #purify(dom, ctx) {
120
190
  let decodedDom = dom;
121
191
  try {
122
- // prevent URIError caused by malformed percent-encoding
123
- // e.g., a standalone '%'
124
192
  decodedDom = decodeURIComponent(dom);
125
- } catch (e) {
193
+ } catch {
126
194
  // fall through
127
195
  }
128
- let purifiedDom = domPurify.sanitize(decodedDom);
129
- if (purifiedDom && REG_DATA_URL.test(purifiedDom)) {
130
- purifiedDom = this.replace(purifiedDom);
196
+ const prevInstance = URLSanitizer.#currentInstance;
197
+ const prevCtx = URLSanitizer.#currentCtx;
198
+ URLSanitizer.#currentInstance = this;
199
+ URLSanitizer.#currentCtx = ctx;
200
+ let purifiedDom;
201
+ try {
202
+ purifiedDom = ctx.domPurify.sanitize(decodedDom);
203
+ } finally {
204
+ URLSanitizer.#currentInstance = prevInstance;
205
+ URLSanitizer.#currentCtx = prevCtx;
131
206
  }
132
- purifiedDom = purifiedDom.replace(/(?:#|%23)$/, '')
133
- .replace(/(?<!(?:#|%23).*)(?:\?|%3F)$/, '');
207
+ purifiedDom = trimTrailingEmptyQueryAndHash(purifiedDom);
134
208
  try {
135
- // prevent URIError caused by lone surrogates
136
209
  return encodeURI(purifiedDom);
137
- } catch (e) {
210
+ } catch {
138
211
  return purifiedDom;
139
212
  }
140
213
  }
141
214
 
142
215
  /**
143
- * sanitize URL
144
- * NOTE: `data` and `file` schemes must be explicitly allowed
145
- * `blob` URLs should be converted to `data` URLs
146
- * `javascript` and `vbscript` schemes can not be allowed
147
- * @param {string} url - URL
148
- * @param {object} [opt] - options
149
- * @param {Array.<string>} [opt.allow] - array of allowed schemes
150
- * @param {Array.<string>} [opt.deny] - array of denied schemes
151
- * @param {Array.<string>} [opt.only] - array of specific schemes to allow
152
- * @param {boolean} [opt.allowRelative] - allow relative URLs
153
- * @param {boolean} [opt.debug] - enable debug mode
154
- * @returns {?string} - sanitized URL
216
+ * Internal recursive method for sanitization.
217
+ * @private
218
+ * @param {string} url - The URL string to sanitize.
219
+ * @param {object} rules - Normalized sanitization rules.
220
+ * @param {object} ctx - Internal context for state management.
221
+ * @returns {string|null} The sanitized URL, or null.
155
222
  */
156
- sanitize(url, opt) {
157
- if (this.#nest > HEX) {
223
+ #process(url, rules, ctx) {
224
+ if (ctx.nest > MAX_NEST) {
158
225
  throw new Error('Data URLs nested too deeply.');
159
226
  }
160
- const {
161
- allow, deny, only, allowRelative = false, debug = false
162
- } = opt ?? {};
163
- const schemeMap = new Map([
164
- ['blob', false],
165
- ['data', false],
166
- ['file', false],
167
- ['javascript', false],
168
- ['vbscript', false]
169
- ]);
170
- const tempScheme = new Set();
227
+ const { allow, deny, only, allowRelative } = rules;
228
+ let allowedSchemes = this.#allowedSchemes;
171
229
  let restrictScheme = false;
172
- if (Array.isArray(only) && only.length) {
173
- const schemes = super.get();
174
- for (const item of schemes) {
175
- schemeMap.set(item, false);
176
- }
177
- const items = Object.values(only);
178
- for (let item of items) {
230
+ const schemeMap = new Map(ctx.schemeMap);
231
+ if (only.length) {
232
+ allowedSchemes = new Set();
233
+ for (let item of only) {
179
234
  if (isString(item)) {
180
235
  item = item.trim();
181
- if (!REG_SCRIPT_BLOB.test(item)) {
182
- if (super.has(item)) {
183
- schemeMap.set(item, true);
184
- } else {
185
- try {
186
- super.add(item);
187
- } catch (e) {
188
- const msg = `Failed to add scheme '${item}' in 'only' list.`;
189
- logDebug(debug, msg, e);
190
- }
191
- if (super.has(item)) {
192
- schemeMap.set(item, true);
193
- tempScheme.add(item);
194
- }
195
- }
196
- if (!restrictScheme && schemeMap.has(item)) {
197
- restrictScheme = schemeMap.get(item);
198
- }
236
+ const registered =
237
+ this.#registerScheme(item, 'only', allowedSchemes, schemeMap, ctx);
238
+ if (registered && !restrictScheme) {
239
+ restrictScheme = true;
199
240
  }
200
241
  }
201
242
  }
202
243
  } else {
203
- if (Array.isArray(allow) && allow.length) {
204
- const items = Object.values(allow);
205
- for (let item of items) {
244
+ if (allow.length) {
245
+ allowedSchemes = new Set(this.#allowedSchemes);
246
+ for (const item of allow) {
206
247
  if (isString(item)) {
207
- item = item.trim();
208
- if (!REG_SCRIPT_BLOB.test(item)) {
209
- if (super.has(item)) {
210
- schemeMap.set(item, true);
211
- } else {
212
- try {
213
- super.add(item);
214
- } catch (e) {
215
- const msg = `Failed to add scheme '${item}' in 'allow' list.`;
216
- logDebug(debug, msg, e);
217
- }
218
- if (super.has(item)) {
219
- schemeMap.set(item, true);
220
- tempScheme.add(item);
221
- }
222
- }
223
- }
248
+ this.#registerScheme(
249
+ item.trim(),
250
+ 'allow',
251
+ allowedSchemes,
252
+ schemeMap,
253
+ ctx
254
+ );
224
255
  }
225
256
  }
226
257
  }
227
- if (Array.isArray(deny) && deny.length) {
228
- const items = Object.values(deny);
229
- for (let item of items) {
258
+ if (deny.length) {
259
+ for (let item of deny) {
230
260
  if (isString(item)) {
231
261
  item = item.trim();
232
262
  if (item) {
@@ -237,17 +267,23 @@ class URLSanitizer extends URISchemes {
237
267
  }
238
268
  }
239
269
  let sanitizedUrl;
240
- let isVerified = super.verify(url);
270
+ let isVerified = super.verify(url, allowedSchemes);
241
271
  let isRelative = false;
242
- if (!isVerified && allowRelative) {
272
+ let relativeParsedPath = '';
273
+ if (!isVerified && allowRelative && !REG_VERIFY_RELATIVE.test(url)) {
243
274
  try {
244
- const { hostname, protocol } = new URL(url, 'http://dummy.local');
245
- if (protocol === 'http:' && hostname === 'dummy.local') {
275
+ const dummyUrl = new URL(url, 'http://dummy.local');
276
+ if (
277
+ dummyUrl.protocol === 'http:' &&
278
+ dummyUrl.hostname === 'dummy.local'
279
+ ) {
246
280
  isVerified = true;
247
281
  isRelative = true;
282
+ relativeParsedPath =
283
+ `${dummyUrl.pathname}${dummyUrl.search}${dummyUrl.hash}`;
248
284
  }
249
285
  } catch (e) {
250
- logDebug(debug, 'Failed to parse relative URL.', e);
286
+ logDebug(ctx.debug, 'Failed to parse relative URL.', e);
251
287
  }
252
288
  }
253
289
  if (isVerified) {
@@ -276,7 +312,7 @@ class URLSanitizer extends URISchemes {
276
312
  }
277
313
  if (bool) {
278
314
  const isDataUrl = isRelative ? false : schemeParts.includes('data');
279
- let urlToSanitize = isRelative ? url : href;
315
+ let urlToSanitize = isRelative ? relativeParsedPath : href;
280
316
  if (isDataUrl) {
281
317
  const [mediaType, ...dataParts] = pathname.split(',');
282
318
  const data = `${dataParts.join(',')}${search}${hash}`;
@@ -284,27 +320,27 @@ class URLSanitizer extends URISchemes {
284
320
  const isBase64 = mediaTypes[mediaTypes.length - 1] === 'base64';
285
321
  let parsedData = data;
286
322
  if (isBase64) {
287
- parsedData = parseBase64(data);
323
+ try {
324
+ parsedData = parseBase64(data);
325
+ } catch (e) {
326
+ logDebug(ctx.debug, 'Failed to parse base64 data.', e);
327
+ urlToSanitize = '';
328
+ }
288
329
  }
289
330
  try {
290
331
  const decodedData = parseURLEncodedNumCharRef(parsedData).trim();
291
- const { protocol: dataScheme } = new URL(decodedData);
332
+ const { protocol: dataScheme } =
333
+ new URL(decodedData, 'http://dummy.local');
292
334
  const dataSchemeParts = dataScheme.replace(/:$/, '').split('+');
293
335
  if (dataSchemeParts.some(s => REG_SCRIPT_BLOB.test(s))) {
294
336
  urlToSanitize = '';
295
337
  }
296
338
  } catch (e) {
297
339
  const msg = 'Failed to parse inner data URL protocol.';
298
- logDebug(debug, msg, e);
299
- }
300
- const containsDataUrl = REG_DATA_URL.test(parsedData);
301
- if (parsedData !== data || containsDataUrl) {
302
- if (containsDataUrl) {
303
- parsedData = this.replace(parsedData);
304
- }
340
+ logDebug(ctx.debug, msg, e);
305
341
  }
306
342
  if (!mediaType || REG_MIME_DOM.test(mediaType)) {
307
- parsedData = this.purify(parsedData);
343
+ parsedData = this.#purify(parsedData, ctx);
308
344
  }
309
345
  if (urlToSanitize && parsedData) {
310
346
  if (isBase64 && parsedData !== data) {
@@ -322,44 +358,89 @@ class URLSanitizer extends URISchemes {
322
358
  urlToSanitize.substring(0, index).replace(/[?&]$/, '');
323
359
  }
324
360
  if (urlToSanitize) {
325
- sanitizedUrl =
326
- urlToSanitize.replace(/%26/g, escapeURLEncodedHTMLChars);
361
+ sanitizedUrl = urlToSanitize;
327
362
  }
328
363
  }
329
- if (tempScheme.size) {
330
- tempScheme.forEach(item => {
331
- super.remove(item);
332
- });
333
- }
334
364
  }
335
365
  return sanitizedUrl || null;
336
366
  }
337
367
 
338
368
  /**
339
- * parse sanitized URL
340
- * @param {string} url - URL
341
- * @param {object} [opt] - options
342
- * @param {Array.<string>} [opt.allow] - array of allowed schemes
343
- * @param {Array.<string>} [opt.deny] - array of denied schemes
344
- * @param {Array.<string>} [opt.only] - array of specific schemes to allow
345
- * @returns {ParsedURL} - result with additional props based on URL API
369
+ * Sanitizes the given URL.
370
+ * @param {string} url - The URL string to sanitize.
371
+ * @param {object} [opt] - Sanitization options.
372
+ * @param {string[]} [opt.allow] - An array of schemes to allow.
373
+ * @param {string[]} [opt.deny] - An array of schemes to deny.
374
+ * @param {string[]} [opt.only] - An array of specific schemes to allow.
375
+ * @param {boolean} [opt.allowRelative] - Flag to safely allow relative URLs.
376
+ * @param {boolean} [opt.debug] - Flag to enable debug mode.
377
+ * @param {number} [opt.maxBlobSize] - The maximum allowed blob size in bytes.
378
+ * @returns {string|null} The sanitized URL, or null.
346
379
  */
347
- parse(url, opt) {
380
+ sanitize(url, opt) {
381
+ if (!url || !isString(url)) {
382
+ return null;
383
+ }
384
+ const hasRestrictiveRules = opt && (
385
+ (Array.isArray(opt.deny) && opt.deny.length > 0) ||
386
+ (Array.isArray(opt.only) && opt.only.length > 0) ||
387
+ opt.allowRelative
388
+ );
389
+ if (
390
+ !hasRestrictiveRules &&
391
+ (url.startsWith('https://') || url.startsWith('http://')) &&
392
+ !REG_TAG_QUOT.test(url) &&
393
+ !url.includes('data:')
394
+ ) {
395
+ try {
396
+ const { href } = new URL(url);
397
+ const res = href;
398
+ return res.replace(/%26/g, escapeURLEncodedHTMLChars);
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+ const rules = {
404
+ allow: Array.isArray(opt?.allow) ? opt.allow : [],
405
+ deny: Array.isArray(opt?.deny) ? opt.deny : [],
406
+ only: Array.isArray(opt?.only) ? opt.only : [],
407
+ allowRelative: !!opt?.allowRelative
408
+ };
409
+ const ctx = {
410
+ debug: !!opt?.debug,
411
+ domPurify,
412
+ nest: 0,
413
+ recurse: null,
414
+ schemeMap: new Map([
415
+ ['blob', false],
416
+ ['data', false],
417
+ ['file', false],
418
+ ['javascript', false],
419
+ ['vbscript', false]
420
+ ])
421
+ };
422
+ return this.#process(url, rules, ctx);
423
+ }
424
+
425
+ /**
426
+ * Inspects, parses, and sanitizes the given URL.
427
+ * NOTE: blob URLs are simply parsed, but neither decoded nor sanitized.
428
+ * @param {string} url - The URL string to parse.
429
+ * @param {object} [opt] - Sanitization options.
430
+ * @returns {InspectedURLResult} The object containing parsed result.
431
+ */
432
+ inspect(url, opt) {
348
433
  if (!isString(url)) {
349
434
  throw new TypeError(`Expected String but got ${getType(url)}.`);
350
435
  }
351
- const parsedUrl = new Map([
352
- ['input', url]
353
- ]);
436
+ const parsedUrl = new Map([['input', url]]);
354
437
  let sanitizedUrl;
355
438
  if (this.verify(url)) {
356
439
  const { protocol } = new URL(url);
357
440
  if (protocol === 'blob:') {
358
441
  sanitizedUrl = url;
359
442
  } else {
360
- sanitizedUrl = this.sanitize(url, opt ?? {
361
- allow: ['data', 'file']
362
- });
443
+ sanitizedUrl = this.sanitize(url, opt ?? { allow: ['data', 'file'] });
363
444
  }
364
445
  }
365
446
  if (sanitizedUrl) {
@@ -371,7 +452,7 @@ class URLSanitizer extends URISchemes {
371
452
  if (isDataUrl) {
372
453
  const dataUrl = new Map();
373
454
  const [mediaType, ...dataParts] = pathname.split(',');
374
- const data = `${dataParts.join(',')}`;
455
+ const data = `${dataParts.join(',')}${urlObj.search}${urlObj.hash}`;
375
456
  const mediaTypes = mediaType.split(';');
376
457
  const isBase64 = mediaTypes[mediaTypes.length - 1] === 'base64';
377
458
  if (isBase64) {
@@ -384,7 +465,7 @@ class URLSanitizer extends URISchemes {
384
465
  } else {
385
466
  parsedUrl.set('data', null);
386
467
  }
387
- for (const key in urlObj) {
468
+ for (const key of URL_PROPS) {
388
469
  const value = urlObj[key];
389
470
  if (isString(value)) {
390
471
  parsedUrl.set(key, value);
@@ -397,31 +478,86 @@ class URLSanitizer extends URISchemes {
397
478
  }
398
479
 
399
480
  /**
400
- * reset sanitizer
481
+ * Gets the list of registered URI schemes.
482
+ * @returns {string[]} An array of registered schemes.
483
+ */
484
+ get() {
485
+ return [...this.#allowedSchemes];
486
+ }
487
+
488
+ /**
489
+ * Checks if the specified scheme is currently registered.
490
+ * @param {string} scheme - The target scheme.
491
+ * @returns {boolean} True if the scheme is registered.
492
+ */
493
+ has(scheme) {
494
+ return this.#allowedSchemes.has(scheme);
495
+ }
496
+
497
+ /**
498
+ * Adds a new scheme to the allowed list.
499
+ * @param {string} scheme - The scheme to add.
500
+ * @returns {string[]} The updated array of registered schemes.
501
+ */
502
+ add(scheme) {
503
+ if (!isString(scheme)) {
504
+ throw new TypeError(`Expected String but got ${getType(scheme)}.`);
505
+ }
506
+ const schemeParts = scheme.split('+');
507
+ const isScript = schemeParts.some(s => REG_SCRIPT.test(s));
508
+ if (isScript || !REG_SCHEME.test(scheme)) {
509
+ throw new Error(`Invalid scheme: ${scheme}`);
510
+ }
511
+ this.#allowedSchemes.add(scheme);
512
+ return [...this.#allowedSchemes];
513
+ }
514
+
515
+ /**
516
+ * Removes a scheme from the allowed list.
517
+ * @param {string} scheme - The scheme to remove.
518
+ * @returns {boolean} True if the scheme was successfully removed.
519
+ */
520
+ remove(scheme) {
521
+ return this.#allowedSchemes.delete(scheme);
522
+ }
523
+
524
+ /**
525
+ * Resets the registered schemes back to the default initial list.
401
526
  * @returns {void}
402
527
  */
403
528
  reset() {
404
- super.reset();
405
- this.#nest = 0;
406
- this.#recurse.clear();
529
+ this.#allowedSchemes = new Set(super.get());
530
+ }
531
+
532
+ /**
533
+ * Verifies if the given URI is valid and its scheme is allowed.
534
+ * @param {string} uri - The URI string to verify.
535
+ * @param {Set<string>} [schemes] - The set of allowed schemes.
536
+ * @returns {boolean} True if the URI is syntactically valid and permitted.
537
+ */
538
+ verify(uri, schemes = this.#allowedSchemes) {
539
+ return super.verify(uri, schemes);
407
540
  }
408
541
  }
409
542
  export { URLSanitizer };
410
543
 
411
- /* aliases and async wrappers */
412
544
  /* instance */
413
545
  const urlSanitizer = new URLSanitizer();
414
546
 
415
547
  /**
416
- * sanitize URL async
417
- * NOTE: `blob`, `data` and `file` schemes must be explicitly allowed
418
- * given a `blob` URL, returns a sanitized `data` URL
548
+ * Asynchronously sanitizes the given URL.
549
+ * NOTE: `blob`, `data`, and `file` schemes must be explicitly allowed.
550
+ * Given a `blob` URL, it securely converts and returns a sanitized `data` URL.
419
551
  * @param {string} url - URL
420
552
  * @param {object} [opt] - options
421
- * @param {Array.<string>} [opt.allow] - array of allowed schemes
422
- * @param {Array.<string>} [opt.deny] - array of denied schemes
423
- * @param {Array.<string>} [opt.only] - array of specific schemes to allow
424
- * @returns {Promise.<?string>} - sanitized URL
553
+ * @param {string[]} [opt.allow] - The array of schemes to allow.
554
+ * @param {string[]} [opt.deny] - The array of schemes to deny.
555
+ * @param {string[]} [opt.only] - The array of specific schemes to allow.
556
+ * @param {boolean} [opt.allowRelative] - Allow relative URLs.
557
+ * @param {boolean} [opt.debug] - Enable debug mode.
558
+ * @param {boolean} [opt.revokeObjectURL] - Revokes the blob URL after sanitization.
559
+ * @param {number} [opt.maxBlobSize] - The maximum allowed blob size in bytes.
560
+ * @returns {Promise<string|null>} A promise resolving to the sanitized URL, or null.
425
561
  */
426
562
  export const sanitizeURL = async (url, opt = {
427
563
  allow: [],
@@ -429,6 +565,7 @@ export const sanitizeURL = async (url, opt = {
429
565
  only: [],
430
566
  allowRelative: false,
431
567
  debug: false,
568
+ revokeObjectURL: false,
432
569
  maxBlobSize: MAX_BLOB_SIZE
433
570
  }) => {
434
571
  const isDebug = !!opt?.debug;
@@ -459,23 +596,25 @@ export const sanitizeURL = async (url, opt = {
459
596
  logDebug(isDebug, msg, e);
460
597
  }
461
598
  if (data) {
462
- if (Array.isArray(only)) {
463
- if (!only.includes('data')) {
464
- only.push('data');
465
- }
466
- } else if (Array.isArray(allow)) {
467
- if (!allow.includes('data')) {
468
- allow.push('data');
469
- }
470
- if (Array.isArray(deny) && deny.includes('data')) {
471
- const i = deny.indexOf('data');
472
- deny.splice(i, 1);
599
+ const options = { ...opt };
600
+ if (Array.isArray(opt.only)) {
601
+ options.only = opt.only.includes('data')
602
+ ? opt.only
603
+ : [...opt.only, 'data'];
604
+ } else if (Array.isArray(opt.allow)) {
605
+ options.allow = opt.allow.includes('data')
606
+ ? opt.allow
607
+ : [...opt.allow, 'data'];
608
+ if (Array.isArray(opt.deny)) {
609
+ options.deny = opt.deny.filter(scheme => scheme !== 'data');
473
610
  }
474
611
  }
475
- res = urlSanitizer.sanitize(data, opt);
612
+ res = urlSanitizer.sanitize(data, options);
476
613
  }
477
614
  }
478
- URL.revokeObjectURL(url);
615
+ if (opt?.revokeObjectURL) {
616
+ URL.revokeObjectURL(url);
617
+ }
479
618
  } else if (scheme || opt.allowRelative) {
480
619
  res = urlSanitizer.sanitize(url, opt);
481
620
  }
@@ -484,17 +623,28 @@ export const sanitizeURL = async (url, opt = {
484
623
  };
485
624
 
486
625
  /**
487
- * sanitize URL sync
488
- * NOTE: `data` and `file` schemes must be explicitly allowed
489
- * `blob` scheme returns `null`
626
+ * Synchronously sanitizes the given URL.
627
+ * NOTE: `data` and `file` schemes must be explicitly allowed.
628
+ * The `blob` scheme is not supported and will return `null`.
490
629
  * @param {string} url - URL
491
630
  * @param {object} [opt] - options
492
- * @param {Array.<string>} [opt.allow] - array of allowed schemes
493
- * @param {Array.<string>} [opt.deny] - array of denied schemes
494
- * @param {Array.<string>} [opt.only] - array of specific schemes to allow
495
- * @returns {?string} - sanitized URL
631
+ * @param {string[]} [opt.allow] - The array of schemes to allow.
632
+ * @param {string[]} [opt.deny] - The array of schemes to deny.
633
+ * @param {string[]} [opt.only] - The array of specific schemes to allow.
634
+ * @param {boolean} [opt.allowRelative] - Allow relative URLs.
635
+ * @param {boolean} [opt.debug] - Enable debug mode.
636
+ * @param {boolean} [opt.revokeObjectURL] - Revokes the blob URL.
637
+ * @returns {string|null} The sanitized URL, or null if denied.
496
638
  */
497
- export const sanitizeURLSync = (url, opt) => {
639
+ export const sanitizeURLSync = (url, opt = {
640
+ allow: [],
641
+ deny: [],
642
+ only: [],
643
+ allowRelative: false,
644
+ debug: false,
645
+ revokeObjectURL: false
646
+ }) => {
647
+ const isDebug = !!opt?.debug;
498
648
  let res;
499
649
  if (url && isString(url)) {
500
650
  let scheme;
@@ -502,12 +652,15 @@ export const sanitizeURLSync = (url, opt) => {
502
652
  const { protocol } = new URL(url);
503
653
  scheme = protocol.replace(/:$/, '');
504
654
  } catch (e) {
505
- const msg = `Failed to parse URL: ${url}`;
506
- logDebug(opt?.debug, msg, e);
655
+ const msg = `Invalid URL input format: ${url}`;
656
+ logDebug(isDebug, msg, e);
507
657
  }
508
658
  if (scheme === 'blob') {
509
- URL.revokeObjectURL(url);
510
- } else if (scheme || opt?.allowRelative) {
659
+ if (opt?.revokeObjectURL) {
660
+ URL.revokeObjectURL(url);
661
+ }
662
+ return null;
663
+ } else if (scheme || opt.allowRelative) {
511
664
  res = urlSanitizer.sanitize(url, opt);
512
665
  }
513
666
  }
@@ -515,26 +668,40 @@ export const sanitizeURLSync = (url, opt) => {
515
668
  };
516
669
 
517
670
  /**
518
- * parse URL async
519
- * @param {string} url - URL
520
- * @returns {Promise.<ParsedURL>} - result
671
+ * Inspects, parses, and sanitizes the given URL asynchronously.
672
+ * @param {string} url - The URL string to inspect.
673
+ * @returns {Promise<InspectedURLResult>} A promise resolving to the inspected URL object.
521
674
  */
522
- export const parseURL = async url => {
523
- const res = urlSanitizer.parse(url);
675
+ export const inspectURL = async url => {
676
+ const res = urlSanitizer.inspect(url);
524
677
  return res;
525
678
  };
526
679
 
527
680
  /**
528
- * parse URL sync
529
- * @param {string} url - URL
530
- * @returns {ParsedURL} - result
681
+ * @deprecated Use inspectURL instead.
682
+ * @param {string} url - The URL string to inspect.
683
+ * @returns {Promise<InspectedURLResult>} A promise resolving to the inspected URL object.
684
+ */
685
+ export const parseURL = url => inspectURL(url);
686
+
687
+ /**
688
+ * Inspects, parses, and sanitizes the given URL synchronously.
689
+ * @param {string} url - The URL string to inspect.
690
+ * @returns {InspectedURLResult} The inspected URL object.
691
+ */
692
+ export const inspectURLSync = url => urlSanitizer.inspect(url);
693
+
694
+ /**
695
+ * @deprecated Use inspectURLSync instead.
696
+ * @param {string} url - The URL string to inspect.
697
+ * @returns {InspectedURLResult} The inspected URL object.
531
698
  */
532
- export const parseURLSync = url => urlSanitizer.parse(url);
699
+ export const parseURLSync = url => inspectURLSync(url);
533
700
 
534
701
  /**
535
- * is URI async
536
- * @param {string} uri - URI
537
- * @returns {Promise.<boolean>} - result
702
+ * Asynchronously checks if the given string is a valid URI and is registered.
703
+ * @param {string} uri - The URI string to verify.
704
+ * @returns {Promise<boolean>} True if valid and registered, false otherwise.
538
705
  */
539
706
  export const isURI = async uri => {
540
707
  const res = urlSanitizer.verify(uri);
@@ -542,9 +709,9 @@ export const isURI = async uri => {
542
709
  };
543
710
 
544
711
  /**
545
- * is URI sync
546
- * @param {string} uri - URI
547
- * @returns {boolean} - result
712
+ * Synchronously checks if the given string is a valid URI and is registered.
713
+ * @param {string} uri - The URI string to verify.
714
+ * @returns {boolean} True if valid and registered, false otherwise.
548
715
  */
549
716
  export const isURISync = uri => urlSanitizer.verify(uri);
550
717