url-sanitizer 4.2.2 → 5.0.1

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.
@@ -7,24 +7,34 @@ import { domPurify } from './dompurify.js';
7
7
  import { getType, isString } from './common.js';
8
8
  import {
9
9
  URISchemes,
10
- convertBlobToDataURL,
11
10
  escapeURLEncodedHTMLChars,
11
+ extractDataUrlComponents,
12
+ getSchemeParts,
12
13
  parseBase64,
13
14
  parseURLEncodedNumCharRef,
14
- trimTrailingEmptyQueryAndHash
15
+ trimTrailingEmptyQueryAndHash,
16
+ truncateURL
15
17
  } from './uri-util.js';
16
18
 
17
19
  /* constants */
18
- import { MAX_BLOB_SIZE, MAX_NEST } from './constant.js';
20
+ import {
21
+ CHUNK_SIZE,
22
+ DECI,
23
+ MAX_BLOB_SIZE,
24
+ MAX_NEST,
25
+ MAX_URL_LENGTH
26
+ } from './constant.js';
19
27
  import {
20
28
  REG_AMP_ENC,
21
29
  REG_MIME_DOM,
22
30
  REG_SCHEME,
23
31
  REG_SCRIPT,
24
- REG_SCRIPT_BLOB,
32
+ REG_SCRIPT_OR_BLOB,
25
33
  REG_TAG_QUOT,
26
34
  REG_VERIFY_RELATIVE
27
35
  } from './regexp.js';
36
+ const DUMMY_BASE = 'http://dummy.local';
37
+ const IS_NODE = globalThis.process?.versions?.node !== undefined;
28
38
  const URL_PROPS = Object.freeze([
29
39
  'href',
30
40
  'origin',
@@ -38,10 +48,15 @@ const URL_PROPS = Object.freeze([
38
48
  'search',
39
49
  'hash'
40
50
  ]);
41
- const INTERNAL_PURIFY_CONFIG = Object.freeze({
42
- RETURN_DOM: false,
43
- RETURN_DOM_FRAGMENT: false,
44
- RETURN_TRUSTED_TYPE: false
51
+ const DEFAULT_OPTS = Object.freeze({
52
+ allow: Object.freeze([]),
53
+ deny: Object.freeze([]),
54
+ only: Object.freeze([]),
55
+ allowRelative: false,
56
+ debug: false,
57
+ revokeObjectURL: false,
58
+ maxBlobSize: MAX_BLOB_SIZE,
59
+ maxLength: MAX_URL_LENGTH
45
60
  });
46
61
 
47
62
  /* typedef */
@@ -58,7 +73,8 @@ const INTERNAL_PURIFY_CONFIG = Object.freeze({
58
73
  * The properties except for input and valid are omitted for invalid URLs.
59
74
  * @typedef {object} InspectedURLResult
60
75
  * @property {string} input - The original URL input.
61
- * @property {boolean} valid - Indicates whether the URI is valid.
76
+ * @property {boolean} valid - Indicates whether the URL passed sanitization rules.
77
+ * @property {boolean} [relative] - Indicates whether the input is a valid relative URL.
62
78
  * @property {string} [reason] - The reason why the URL is invalid.
63
79
  * @property {InspectedDataURL | null} [data] - The parsed result of a data URL. Null if not a data URL.
64
80
  * @property {string} [href] - The sanitized URL input.
@@ -76,99 +92,71 @@ const INTERNAL_PURIFY_CONFIG = Object.freeze({
76
92
 
77
93
  /**
78
94
  * Internal debug logger.
79
- * @param {boolean} isDebug - Flag to enable or disable debug output.
80
95
  * @param {string} message - The message to output.
81
96
  * @param {Error} [error] - The caught error object, if any.
82
97
  * @returns {void}
83
98
  */
84
- export const logDebug = (isDebug, message, error) => {
85
- if (isDebug) {
86
- console.warn(`[URLSanitizer Debug] ${message}`, error);
87
- }
99
+ export const logDebug = (message, error) => {
100
+ console.warn(`[URLSanitizer Debug] ${message}`, error);
88
101
  };
89
102
 
90
103
  /**
91
- * URL sanitizer
104
+ * Context manager for the sanitization process.
92
105
  */
93
- class URLSanitizer extends URISchemes {
94
- /* private fields */
95
- #allowedSchemes;
96
-
106
+ export class SanitizeContext {
97
107
  /**
98
- * NOTE: Global Context Pattern for DOMPurify Hooks
99
- * - DOMPurify hooks (e.g., 'uponSanitizeAttribute') are registered globally
100
- * and do not accept custom arguments (like `this` or `ctx`).
101
- * To pass the current sanitization context into the hook, we temporarily
102
- * store it in static fields (#currentInstance and #currentCtx).
103
- * - This is entirely safe and immune to race conditions because JavaScript
104
- * runs on a single thread and DOMPurify's sanitize() is synchronous.
105
- * The context is safely restored via try/finally block in #purify() even
106
- * during recursive calls.
108
+ * @param {object} opt - Sanitization options.
109
+ * @param {object} domPurifyInstance - The DOMPurify instance.
107
110
  */
108
- /** @type {URLSanitizer | null} */
109
- static #currentInstance = null;
110
- static #currentCtx = null;
111
+ constructor(opt, domPurifyInstance) {
112
+ this.debug = !!opt?.debug;
113
+ this.domPurify = domPurifyInstance;
114
+ this.nest = 0;
115
+ this.recurse = new Set();
116
+ this.schemeMap = new Map([
117
+ ['blob', false],
118
+ ['data', false],
119
+ ['file', false],
120
+ ['javascript', false],
121
+ ['vbscript', false]
122
+ ]);
123
+ }
111
124
 
112
125
  /**
113
- * DOMPurify 'uponSanitizeAttribute' hook callback.
114
- * @private
115
- * @static
116
- * @param {Node} node - The DOM node being sanitized.
117
- * @param {object} evt - The event object containing attribute details.
126
+ * Enters a new level of nested URL sanitization.
127
+ * Tracks the URL to prevent circular references.
128
+ * @param {string} url - The URL being processed.
129
+ * @returns {boolean} True if it is safe to proceed, false if circular reference detected.
118
130
  */
119
- static #uponSanitizeAttribute(node, evt) {
120
- if (!evt.attrValue || !/^\s*data:/i.test(evt.attrValue)) {
121
- return;
122
- }
123
- /** @type {URLSanitizer | null} */
124
- const sanitizer = URLSanitizer.#currentInstance;
125
- const ctx = URLSanitizer.#currentCtx;
126
- if (!sanitizer || !ctx) {
127
- return;
128
- }
129
- const urlObj = URL.parse(evt.attrValue);
130
- if (!urlObj) {
131
- return;
132
- }
133
- if (urlObj.protocol === 'data:') {
134
- const originalUrl = evt.attrValue;
135
- if (!ctx.recurse) {
136
- ctx.recurse = new Set();
137
- }
138
- if (ctx.recurse.has(originalUrl)) {
139
- const msg = `Circular Data URL detected and skipped: ${originalUrl}`;
140
- logDebug(ctx.debug, msg);
141
- evt.attrValue = '';
142
- return;
143
- }
144
- ctx.nest++;
145
- ctx.recurse.add(originalUrl);
146
- try {
147
- const sanitized = sanitizer.#process(
148
- originalUrl,
149
- {
150
- allow: ['data'],
151
- deny: [],
152
- only: [],
153
- allowRelative: false
154
- },
155
- ctx
156
- );
157
- evt.attrValue = sanitized || '';
158
- } finally {
159
- ctx.nest--;
160
- ctx.recurse.delete(originalUrl);
131
+ enter(url) {
132
+ if (this.recurse.has(url)) {
133
+ if (this.debug) {
134
+ logDebug(`Circular Data URL detected and skipped: ${truncateURL(url)}`);
161
135
  }
136
+ return false;
162
137
  }
138
+ this.nest++;
139
+ this.recurse.add(url);
140
+ return true;
163
141
  }
164
142
 
165
- static {
166
- domPurify.setConfig(INTERNAL_PURIFY_CONFIG);
167
- domPurify.addHook(
168
- 'uponSanitizeAttribute',
169
- URLSanitizer.#uponSanitizeAttribute
170
- );
143
+ /**
144
+ * Leaves the current level of nested URL sanitization.
145
+ * Cleans up the tracking state after processing the URL.
146
+ * @param {string} url - The URL that finished processing.
147
+ */
148
+ leave(url) {
149
+ this.nest--;
150
+ this.recurse.delete(url);
171
151
  }
152
+ }
153
+
154
+ /**
155
+ * URL sanitizer
156
+ */
157
+ export class URLSanitizer extends URISchemes {
158
+ /* private fields */
159
+ #allowedSchemes;
172
160
 
173
161
  constructor() {
174
162
  super();
@@ -176,79 +164,61 @@ class URLSanitizer extends URISchemes {
176
164
  }
177
165
 
178
166
  /**
179
- * Helper method to register schemes for the 'allow' or 'only' options.
167
+ * Executes the core sanitization logic.
180
168
  * @private
181
- * @param {string} item - The scheme to register.
182
- * @param {string} listName - The name of the target option list.
183
- * @param {Set<string>} allowedSchemes - The local set of allowed schemes.
184
- * @param {Map<string, boolean>} schemeMap - The local map of schemes.
185
- * @param {object} ctx - The context for state management.
186
- * @returns {boolean} True if the scheme is successfully registered.
187
- */
188
- #registerScheme(item, listName, allowedSchemes, schemeMap, ctx) {
189
- if (REG_SCRIPT_BLOB.test(item)) {
190
- return false;
191
- }
192
- const schemeParts = item.split('+');
193
- const isScript = schemeParts.some(s => REG_SCRIPT.test(s));
194
- if (isScript || !REG_SCHEME.test(item)) {
195
- const msg = `Failed to add scheme '${item}' in '${listName}' list.`;
196
- logDebug(ctx.debug, msg);
197
- return false;
198
- }
199
- schemeMap.set(item, true);
200
- allowedSchemes.add(item);
201
- return true;
202
- }
203
-
204
- /**
205
- * Purifies a URL-encoded DOM string to prevent XSS.
206
- * @private
207
- * @param {string} dom - The URL-encoded DOM string.
208
- * @param {object} ctx - The context for state management.
209
- * @returns {string} The purified DOM string.
169
+ * @param {string} url - The URL string to sanitize.
170
+ * @param {object} opt - Sanitization options.
171
+ * @returns {string|null} The sanitized URL, or null.
210
172
  */
211
- #purify(dom, ctx) {
212
- let decodedDom = dom;
213
- try {
214
- decodedDom = decodeURIComponent(dom);
215
- } catch {
216
- // fall through
173
+ #executeSanitize(url, opt) {
174
+ if (!url || !isString(url)) {
175
+ return null;
217
176
  }
218
- const prevInstance = URLSanitizer.#currentInstance;
219
- const prevCtx = URLSanitizer.#currentCtx;
220
- URLSanitizer.#currentInstance = this;
221
- URLSanitizer.#currentCtx = ctx;
222
- let purifiedDom;
223
- try {
224
- purifiedDom = ctx.domPurify.sanitize(decodedDom);
225
- } finally {
226
- URLSanitizer.#currentInstance = prevInstance;
227
- URLSanitizer.#currentCtx = prevCtx;
177
+ const { allow, allowRelative, deny, maxLength, only } = opt;
178
+ if (Number.isInteger(maxLength) && url.length > maxLength) {
179
+ const msg = `URL length ${url.length} exceeds max length ${maxLength}.`;
180
+ throw new RangeError(msg);
228
181
  }
229
- purifiedDom = trimTrailingEmptyQueryAndHash(purifiedDom);
230
- try {
231
- return encodeURI(purifiedDom);
232
- } catch {
233
- return purifiedDom;
182
+ const hasRestrictiveRules =
183
+ (Array.isArray(deny) && deny.length) ||
184
+ (Array.isArray(only) && only.length) ||
185
+ allowRelative;
186
+ // Early return for standard HTTP/HTTPS URLs without restrictive rules.
187
+ if (
188
+ !hasRestrictiveRules &&
189
+ (url.startsWith('https://') || url.startsWith('http://')) &&
190
+ !REG_TAG_QUOT.test(url) &&
191
+ !url.includes('data:')
192
+ ) {
193
+ const urlObj = this.parse(url);
194
+ if (urlObj) {
195
+ return urlObj.href.replace(/%26/g, escapeURLEncodedHTMLChars);
196
+ }
197
+ return null;
234
198
  }
199
+ const ctx = new SanitizeContext(opt, domPurify);
200
+ return this.#process(url, { allow, allowRelative, deny, only }, ctx);
235
201
  }
236
202
 
237
203
  /**
238
204
  * Internal recursive method for sanitization.
239
205
  * @private
240
206
  * @param {string} url - The URL string to sanitize.
241
- * @param {object} rules - Normalized sanitization rules.
242
- * @param {object} ctx - Internal context for state.
207
+ * @param {object} rules - The sanitization rules.
208
+ * @param {string[]} rules.allow - Allowed schemes.
209
+ * @param {boolean} rules.allowRelative - Allow relative URLs.
210
+ * @param {string[]} rules.deny - Denied schemes.
211
+ * @param {string[]} rules.only - Exclusively allowed schemes.
212
+ * @param {SanitizeContext} ctx - Internal context for state.
243
213
  * @returns {string|null} The sanitized URL, or null.
244
214
  */
245
- #process(url, rules, ctx) {
215
+ #process(url, { allow, allowRelative, deny, only }, ctx) {
246
216
  if (ctx.nest > MAX_NEST) {
247
217
  throw new Error('Data URLs nested too deeply.');
248
218
  }
249
219
  // Resolve allowed/denied schemes
250
220
  const { allowedSchemes, restrictScheme, schemeMap } =
251
- this.#resolveSchemeRules(rules, ctx);
221
+ this.#resolveSchemeRules({ allow, deny, only }, ctx);
252
222
  // Parse and verify the URL
253
223
  const {
254
224
  isDataUrl,
@@ -258,7 +228,7 @@ class URLSanitizer extends URISchemes {
258
228
  schemeParts,
259
229
  urlObj,
260
230
  urlToSanitize
261
- } = this.#parseAndVerifyURL(url, rules.allowRelative, allowedSchemes, ctx);
231
+ } = this.#parseAndVerifyURL(url, allowRelative, allowedSchemes, ctx);
262
232
  if (!isVerified) {
263
233
  return null;
264
234
  }
@@ -288,50 +258,36 @@ class URLSanitizer extends URISchemes {
288
258
  * @param {string[]} rules.allow - Allowed schemes.
289
259
  * @param {string[]} rules.deny - Denied schemes.
290
260
  * @param {string[]} rules.only - Exclusively allowed schemes.
291
- * @param {object} ctx - Context for state management.
261
+ * @param {SanitizeContext} ctx - Context for state management.
292
262
  * @returns {object} Resolved scheme objects and flags.
293
263
  */
294
264
  #resolveSchemeRules({ allow, deny, only }, ctx) {
295
265
  let allowedSchemes = this.#allowedSchemes;
296
266
  let restrictScheme = false;
297
267
  const schemeMap = new Map(ctx.schemeMap);
298
- if (only.length) {
268
+ if (Array.isArray(only) && only.length) {
299
269
  allowedSchemes = new Set();
270
+ restrictScheme = true;
300
271
  for (const item of only) {
301
272
  if (isString(item)) {
302
- const registered = this.#registerScheme(
303
- item.trim(),
304
- 'only',
305
- allowedSchemes,
306
- schemeMap,
307
- ctx
308
- );
309
- if (registered && !restrictScheme) {
310
- restrictScheme = true;
311
- }
273
+ this.#registerScheme(item, 'only', allowedSchemes, schemeMap, ctx);
312
274
  }
313
275
  }
314
276
  } else {
315
- if (allow.length) {
277
+ if (Array.isArray(allow) && allow.length) {
316
278
  allowedSchemes = new Set(this.#allowedSchemes);
317
279
  for (const item of allow) {
318
280
  if (isString(item)) {
319
- this.#registerScheme(
320
- item.trim(),
321
- 'allow',
322
- allowedSchemes,
323
- schemeMap,
324
- ctx
325
- );
281
+ this.#registerScheme(item, 'allow', allowedSchemes, schemeMap, ctx);
326
282
  }
327
283
  }
328
284
  }
329
- if (deny.length) {
330
- for (let item of deny) {
285
+ if (Array.isArray(deny) && deny.length) {
286
+ for (const item of deny) {
331
287
  if (isString(item)) {
332
- item = item.trim();
333
- if (item) {
334
- schemeMap.set(item, false);
288
+ const normalized = this.normalize(item, true);
289
+ if (normalized) {
290
+ schemeMap.set(normalized, false);
335
291
  }
336
292
  }
337
293
  }
@@ -340,36 +296,67 @@ class URLSanitizer extends URISchemes {
340
296
  return { allowedSchemes, restrictScheme, schemeMap };
341
297
  }
342
298
 
299
+ /**
300
+ * Helper method to register schemes for the 'allow' or 'only' options.
301
+ * @private
302
+ * @param {string} item - The scheme to register.
303
+ * @param {string} listName - The name of the target option list.
304
+ * @param {Set<string>} allowedSchemes - The local set of allowed schemes.
305
+ * @param {Map<string, boolean>} schemeMap - The local map of schemes.
306
+ * @param {SanitizeContext} ctx - The context for state management.
307
+ * @returns {boolean} True if the scheme is successfully registered.
308
+ */
309
+ #registerScheme(item, listName, allowedSchemes, schemeMap, ctx) {
310
+ const normalizedScheme = this.normalize(item, true);
311
+ if (!this.#isValidScheme(normalizedScheme)) {
312
+ return false;
313
+ }
314
+ schemeMap.set(normalizedScheme, true);
315
+ allowedSchemes.add(normalizedScheme);
316
+ return true;
317
+ }
318
+
319
+ /**
320
+ * Validates if a normalized URI scheme is syntactically correct.
321
+ * @private
322
+ * @param {string} normalizedScheme - The normalized URI scheme to validate.
323
+ * @returns {boolean} True if the scheme satisfies the syntax and security requirements.
324
+ */
325
+ #isValidScheme(normalizedScheme) {
326
+ if (REG_SCRIPT_OR_BLOB.test(normalizedScheme)) {
327
+ return false;
328
+ }
329
+ const schemeParts = normalizedScheme.split('+');
330
+ const isScript = schemeParts.some(s => REG_SCRIPT.test(s));
331
+ return !isScript && REG_SCHEME.test(normalizedScheme);
332
+ }
333
+
343
334
  /**
344
335
  * Parses and verifies absolute and relative URLs.
345
336
  * @private
346
337
  * @param {string} url - The URL to parse.
347
- * @param {boolean} allowRelative - If relative URLs are OK.
338
+ * @param {boolean} allowRelative - Allow relative URLs.
348
339
  * @param {Set<string>} allowedSchemes - Permitted schemes.
349
- * @param {object} ctx - Context for logging and state.
340
+ * @param {SanitizeContext} ctx - Context for logging and state.
350
341
  * @returns {object} Parsed URL properties and flags.
351
342
  */
352
343
  #parseAndVerifyURL(url, allowRelative, allowedSchemes, ctx) {
353
- let isVerified = super.verify(url, allowedSchemes);
344
+ const urlObj = this.parse(url);
345
+ let isVerified = this.verifyParsed(urlObj, allowedSchemes);
354
346
  let isRelative = false;
355
- let relativeParsedPath = '';
347
+ let relativePath = '';
356
348
  // Handle Relative URLs
357
349
  if (!isVerified && allowRelative && !REG_VERIFY_RELATIVE.test(url)) {
358
- const normUrl = url.normalize('NFKC');
359
- const dummyUrl = URL.parse(url, 'http://dummy.local');
360
- const dummyUrlNormalized = URL.parse(normUrl, 'http://dummy.local');
361
- if (dummyUrl && dummyUrlNormalized) {
350
+ const dummyUrl = this.parse(url, DUMMY_BASE);
351
+ if (dummyUrl) {
362
352
  if (
363
353
  dummyUrl.protocol === 'http:' &&
364
- dummyUrl.hostname === 'dummy.local' &&
365
- dummyUrlNormalized.protocol === 'http:'
354
+ dummyUrl.hostname === 'dummy.local'
366
355
  ) {
367
356
  isVerified = true;
368
357
  isRelative = true;
369
- relativeParsedPath = `${dummyUrl.pathname}${dummyUrl.search}${dummyUrl.hash}`;
358
+ relativePath = `${dummyUrl.pathname}${dummyUrl.search}${dummyUrl.hash}`;
370
359
  }
371
- } else {
372
- logDebug(ctx.debug, 'Failed to parse relative URL.');
373
360
  }
374
361
  }
375
362
  if (!isVerified) {
@@ -383,11 +370,10 @@ class URLSanitizer extends URISchemes {
383
370
  isDataUrl: false,
384
371
  scheme: '',
385
372
  schemeParts: [],
386
- urlToSanitize: relativeParsedPath
373
+ urlToSanitize: relativePath
387
374
  };
388
375
  }
389
- const urlObj = URL.parse(url);
390
- const scheme = urlObj.protocol.replace(/:$/, '').normalize('NFKC');
376
+ const scheme = urlObj.protocol.replace(/:$/, '');
391
377
  const schemeParts = scheme.split('+');
392
378
  return {
393
379
  isRelative,
@@ -395,7 +381,7 @@ class URLSanitizer extends URISchemes {
395
381
  scheme,
396
382
  schemeParts,
397
383
  urlObj,
398
- isDataUrl: schemeParts.includes('data'),
384
+ isDataUrl: scheme === 'data',
399
385
  urlToSanitize: urlObj.href
400
386
  };
401
387
  }
@@ -415,7 +401,7 @@ class URLSanitizer extends URISchemes {
415
401
  return true;
416
402
  }
417
403
  if (restrictScheme) {
418
- return schemeParts.every(s => schemeMap.get(s));
404
+ return schemeMap.has(scheme) || schemeParts.every(s => schemeMap.get(s));
419
405
  }
420
406
  for (const [key, value] of schemeMap.entries()) {
421
407
  const bool =
@@ -432,39 +418,41 @@ class URLSanitizer extends URISchemes {
432
418
  * @private
433
419
  * @param {object} urlObj - The URL object.
434
420
  * @param {string} scheme - The URL scheme.
435
- * @param {object} ctx - Context for DOMPurify sanitization.
421
+ * @param {SanitizeContext} ctx - Context for DOMPurify sanitization.
436
422
  * @returns {string|null} Sanitized data URL or null.
437
423
  */
438
424
  #sanitizeDataURL(urlObj, scheme, ctx) {
439
- const [mediaType, ...dataParts] = urlObj.pathname.split(',');
440
- const data = `${dataParts.join(',')}${urlObj.search}${urlObj.hash}`;
441
- const mediaTypes = mediaType.split(';');
442
- const isBase64 = mediaTypes[mediaTypes.length - 1] === 'base64';
425
+ const { mediaType, mediaTypes, data, isBase64 } = extractDataUrlComponents(
426
+ urlObj.pathname,
427
+ urlObj.search,
428
+ urlObj.hash
429
+ );
443
430
  let parsedData = data;
444
431
  if (isBase64) {
445
432
  try {
446
433
  parsedData = parseBase64(data);
447
434
  } catch (e) {
448
- logDebug(ctx.debug, 'Failed to parse base64 data.', e);
435
+ if (ctx.debug) {
436
+ logDebug('Failed to parse base64 data.', e);
437
+ }
449
438
  return null;
450
439
  }
451
440
  }
452
441
  try {
453
442
  const decodedData = parseURLEncodedNumCharRef(parsedData).trim();
454
- const normalizedData = decodedData.normalize('NFKC');
455
- const dummy = 'http://dummy.local';
456
- const parsedUrl = URL.parse(normalizedData, dummy);
443
+ const parsedUrl = this.parse(decodedData, DUMMY_BASE);
457
444
  if (!parsedUrl) {
458
- logDebug(ctx.debug, 'Failed to parse inner data URL protocol.');
459
445
  return null;
460
446
  }
461
447
  const dataScheme = parsedUrl.protocol;
462
- const dataSchemeParts = dataScheme.replace(/:$/, '').split('+');
463
- if (dataSchemeParts.some(s => REG_SCRIPT_BLOB.test(s))) {
448
+ const dataSchemeParts = getSchemeParts(dataScheme);
449
+ if (dataSchemeParts.some(s => REG_SCRIPT_OR_BLOB.test(s))) {
464
450
  return null;
465
451
  }
466
452
  } catch (e) {
467
- logDebug(ctx.debug, 'Failed to parse inner data URL protocol.', e);
453
+ if (ctx.debug) {
454
+ logDebug('Failed to parse inner data URL protocol.', e);
455
+ }
468
456
  return null;
469
457
  }
470
458
  if (!mediaType || REG_MIME_DOM.test(mediaType)) {
@@ -479,6 +467,69 @@ class URLSanitizer extends URISchemes {
479
467
  return null;
480
468
  }
481
469
 
470
+ /**
471
+ * Purifies a URL-encoded DOM string to prevent XSS.
472
+ * @private
473
+ * @param {string} dom - The URL-encoded DOM string.
474
+ * @param {SanitizeContext} ctx - The context for state management.
475
+ * @returns {string} The purified DOM string.
476
+ */
477
+ #purify(dom, ctx) {
478
+ let decodedDom = dom;
479
+ try {
480
+ decodedDom = decodeURIComponent(dom);
481
+ } catch {
482
+ // fall through
483
+ }
484
+ let purifiedDom;
485
+ const tempHook = (node, evt) =>
486
+ this.#handleSanitizeAttribute(node, evt, ctx);
487
+ ctx.domPurify.addHook('uponSanitizeAttribute', tempHook);
488
+ try {
489
+ purifiedDom = ctx.domPurify.sanitize(decodedDom);
490
+ } finally {
491
+ ctx.domPurify.removeHook('uponSanitizeAttribute');
492
+ }
493
+ purifiedDom = trimTrailingEmptyQueryAndHash(purifiedDom);
494
+ try {
495
+ return encodeURI(purifiedDom);
496
+ } catch {
497
+ return purifiedDom;
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Internal handler for the DOMPurify hook.
503
+ * @private
504
+ * @param {Node} node - The DOM node.
505
+ * @param {object} evt - The event object.
506
+ * @param {SanitizeContext} ctx - The sanitization context.
507
+ */
508
+ #handleSanitizeAttribute(node, evt, ctx) {
509
+ if (!evt.attrValue || !/^\s*data:/i.test(evt.attrValue)) {
510
+ return;
511
+ }
512
+ const urlObj = this.parse(evt.attrValue);
513
+ if (!urlObj || urlObj.protocol !== 'data:') {
514
+ return;
515
+ }
516
+ const originalUrl = evt.attrValue;
517
+ if (!ctx.enter(originalUrl)) {
518
+ evt.attrValue = '';
519
+ return;
520
+ }
521
+ try {
522
+ const sanitized = this.#process(
523
+ originalUrl,
524
+ { allow: ['data'], deny: [], only: [], allowRelative: false },
525
+ ctx
526
+ );
527
+ evt.attrValue = sanitized || '';
528
+ } finally {
529
+ ctx.leave(originalUrl);
530
+ }
531
+ }
532
+
482
533
  /**
483
534
  * Applies regex cleanups to standard non-data URLs.
484
535
  * Strips out trailing queries or problematic characters.
@@ -488,13 +539,12 @@ class URLSanitizer extends URISchemes {
488
539
  */
489
540
  #sanitizeStandardURL(urlToSanitize) {
490
541
  let sanitized = urlToSanitize;
491
- if (REG_TAG_QUOT.test(sanitized)) {
492
- const { index } = REG_TAG_QUOT.exec(sanitized);
493
- sanitized = sanitized.substring(0, index).replace(/[?&]$/, '');
494
- }
495
- if (REG_AMP_ENC.test(sanitized)) {
496
- const { index } = REG_AMP_ENC.exec(sanitized);
497
- sanitized = sanitized.substring(0, index).replace(/[?&]$/, '');
542
+ const patterns = [REG_TAG_QUOT, REG_AMP_ENC];
543
+ for (const pattern of patterns) {
544
+ const match = pattern.exec(sanitized);
545
+ if (match) {
546
+ sanitized = sanitized.substring(0, match.index).replace(/[?&]$/, '');
547
+ }
498
548
  }
499
549
  return sanitized;
500
550
  }
@@ -506,136 +556,94 @@ class URLSanitizer extends URISchemes {
506
556
  * @param {string[]} [opt.allow] - An array of schemes to allow.
507
557
  * @param {string[]} [opt.deny] - An array of schemes to deny.
508
558
  * @param {string[]} [opt.only] - An array of specific schemes to allow.
509
- * @param {boolean} [opt.allowRelative] - Flag to safely allow relative URLs.
559
+ * @param {boolean} [opt.allowRelative] - Allow relative URLs.
510
560
  * @param {boolean} [opt.debug] - Flag to enable debug mode.
511
561
  * @param {number} [opt.maxBlobSize] - The maximum allowed blob size in bytes.
512
562
  * @param {number} [opt.maxLength] - The maximum allowed URL length.
513
563
  * @returns {string|null} The sanitized URL, or null.
514
564
  */
515
- sanitize(url, opt) {
516
- if (!url || !isString(url)) {
517
- return null;
518
- }
519
- const maxLength =
520
- Number.isInteger(opt?.maxLength) && opt.maxLength ? opt.maxLength : 0;
521
- if (maxLength && url.length > maxLength) {
522
- const msg = `URL length ${url.length} exceeds maxLength ${maxLength}.`;
523
- throw new RangeError(msg);
524
- }
525
- const hasRestrictiveRules =
526
- opt &&
527
- ((Array.isArray(opt.deny) && opt.deny.length > 0) ||
528
- (Array.isArray(opt.only) && opt.only.length > 0) ||
529
- opt.allowRelative);
530
- if (
531
- !hasRestrictiveRules &&
532
- (url.startsWith('https://') || url.startsWith('http://')) &&
533
- !REG_TAG_QUOT.test(url) &&
534
- !url.includes('data:')
535
- ) {
536
- const parsedUrl = URL.parse(url);
537
- if (parsedUrl) {
538
- return parsedUrl.href.replace(/%26/g, escapeURLEncodedHTMLChars);
539
- } else {
540
- logDebug(opt?.debug, `Failed to parse URL.`);
541
- return null;
542
- }
543
- }
544
- const rules = {
545
- allow: Array.isArray(opt?.allow) ? opt.allow : [],
546
- deny: Array.isArray(opt?.deny) ? opt.deny : [],
547
- only: Array.isArray(opt?.only) ? opt.only : [],
548
- allowRelative: !!opt?.allowRelative
565
+ sanitize(url, opt = {}) {
566
+ const options = {
567
+ ...DEFAULT_OPTS,
568
+ ...opt
549
569
  };
550
- const ctx = {
551
- debug: !!opt?.debug,
552
- domPurify,
553
- nest: 0,
554
- recurse: null,
555
- schemeMap: new Map([
556
- ['blob', false],
557
- ['data', false],
558
- ['file', false],
559
- ['javascript', false],
560
- ['vbscript', false]
561
- ])
562
- };
563
- return this.#process(url, rules, ctx);
570
+ return this.#executeSanitize(url, options);
564
571
  }
565
572
 
566
573
  /**
567
574
  * Inspects, parses, and sanitizes the given URL.
568
- * NOTE: blob URLs are simply parsed, but neither decoded nor sanitized.
569
575
  * @param {string} url - The URL string to parse.
570
- * @param {object} [opt] - Sanitization options.
571
576
  * @returns {InspectedURLResult} The result of an inspected URL.
572
577
  */
573
- inspect(url, opt) {
574
- if (!isString(url)) {
575
- throw new TypeError(`Expected String but got ${getType(url)}.`);
576
- }
578
+ inspect(url) {
577
579
  const inspectedURL = {
578
580
  input: url,
579
581
  valid: false
580
582
  };
583
+ if (!isString(url)) {
584
+ inspectedURL.reason = `Invalid URL input: ${truncateURL(url)}`;
585
+ return inspectedURL;
586
+ }
587
+ if (url === '') {
588
+ inspectedURL.reason = 'Invalid URL input: (empty string)';
589
+ return inspectedURL;
590
+ }
581
591
  let sanitizedUrl;
582
592
  let invalidReason = null;
583
- const maxLength =
584
- Number.isInteger(opt?.maxLength) && opt.maxLength ? opt.maxLength : 0;
585
- const urlLength = url.length;
586
- if (maxLength && urlLength > maxLength) {
587
- invalidReason = `URL length ${urlLength} exceeds maxLength ${maxLength}.`;
588
- } else if (this.verify(url)) {
589
- const { protocol } = new URL(url);
590
- if (protocol === 'blob:') {
591
- sanitizedUrl = url;
592
- } else {
593
- try {
594
- sanitizedUrl = this.sanitize(url, opt ?? { allow: ['data'] });
595
- if (!sanitizedUrl) {
596
- invalidReason =
597
- 'Sanitization failed (blocked by allowed schemes or rules).';
598
- }
599
- } catch (e) {
600
- invalidReason = e.message;
601
- }
593
+ try {
594
+ sanitizedUrl = this.#executeSanitize(url, {
595
+ ...DEFAULT_OPTS,
596
+ allow: ['data'],
597
+ allowRelative: true
598
+ });
599
+ if (!sanitizedUrl) {
600
+ invalidReason =
601
+ 'Sanitization failed (blocked by allowed schemes or rules).';
602
602
  }
603
- } else {
604
- invalidReason = 'Invalid URI syntax or scheme is not registered.';
603
+ } catch (e) {
604
+ invalidReason = e.message;
605
605
  }
606
606
  if (sanitizedUrl) {
607
- const urlObj = new URL(sanitizedUrl);
608
- const { pathname, protocol } = urlObj;
609
- const schemeParts = protocol.replace(/:$/, '').split('+');
610
- const isDataUrl = schemeParts.includes('data');
607
+ // Reparse the sanitized string to safely extract updated properties.
608
+ const urlObj = this.parse(sanitizedUrl);
611
609
  inspectedURL.valid = true;
612
- if (isDataUrl) {
613
- const [mediaType, ...dataParts] = pathname.split(',');
614
- const data = `${dataParts.join(',')}${urlObj.search}${urlObj.hash}`;
615
- const mediaTypes = mediaType.split(';');
616
- const isBase64 = mediaTypes[mediaTypes.length - 1] === 'base64';
617
- if (isBase64) {
618
- mediaTypes.pop();
610
+ if (urlObj) {
611
+ const schemeParts = getSchemeParts(urlObj.protocol);
612
+ const isDataUrl = schemeParts.includes('data');
613
+ if (isDataUrl) {
614
+ const { mediaTypes, data, isBase64 } = extractDataUrlComponents(
615
+ urlObj.pathname,
616
+ urlObj.search,
617
+ urlObj.hash
618
+ );
619
+ if (isBase64) {
620
+ mediaTypes.pop();
621
+ }
622
+ inspectedURL.data = {
623
+ mime: mediaTypes.join(';'),
624
+ base64: isBase64,
625
+ data
626
+ };
627
+ } else {
628
+ inspectedURL.data = null;
619
629
  }
620
- inspectedURL.data = {
621
- mime: mediaTypes.join(';'),
622
- base64: isBase64,
623
- data
624
- };
625
- } else {
626
- inspectedURL.data = null;
627
- }
628
- for (const key of URL_PROPS) {
629
- const value = urlObj[key];
630
- if (isString(value)) {
631
- inspectedURL[key] = value;
630
+ for (const key of URL_PROPS) {
631
+ const value = urlObj[key];
632
+ if (isString(value)) {
633
+ inspectedURL[key] = value;
634
+ }
632
635
  }
636
+ } else {
637
+ // Fallback for the valid relative URL without a base URL.
638
+ inspectedURL.valid = false;
639
+ inspectedURL.relative = true;
640
+ inspectedURL.href = sanitizedUrl;
641
+ inspectedURL.reason =
642
+ 'Invalid as an absolute URL, but valid as a relative URL.';
633
643
  }
634
644
  } else {
635
645
  inspectedURL.valid = false;
636
- if (invalidReason) {
637
- inspectedURL.reason = invalidReason;
638
- }
646
+ inspectedURL.reason = invalidReason;
639
647
  }
640
648
  return inspectedURL;
641
649
  }
@@ -654,10 +662,11 @@ class URLSanitizer extends URISchemes {
654
662
  * @returns {boolean} True if the scheme is registered.
655
663
  */
656
664
  has(scheme) {
657
- if (!isString(scheme)) {
658
- return false;
665
+ const normalizedScheme = this.normalize(scheme, true);
666
+ if (normalizedScheme) {
667
+ return this.#allowedSchemes.has(normalizedScheme);
659
668
  }
660
- return this.#allowedSchemes.has(scheme.trim().toLowerCase());
669
+ return false;
661
670
  }
662
671
 
663
672
  /**
@@ -666,18 +675,14 @@ class URLSanitizer extends URISchemes {
666
675
  * @returns {string[]} The updated array of registered schemes.
667
676
  */
668
677
  add(scheme) {
669
- let loweredScheme;
670
- if (isString(scheme)) {
671
- loweredScheme = scheme.trim().toLowerCase();
672
- } else {
678
+ if (!isString(scheme)) {
673
679
  throw new TypeError(`Expected String but got ${getType(scheme)}.`);
674
680
  }
675
- const schemeParts = loweredScheme.split('+');
676
- const isScript = schemeParts.some(s => REG_SCRIPT.test(s));
677
- if (isScript || !REG_SCHEME.test(loweredScheme)) {
681
+ const normalizedScheme = this.normalize(scheme, true);
682
+ if (!this.#isValidScheme(normalizedScheme)) {
678
683
  throw new Error(`Invalid scheme: ${scheme}`);
679
684
  }
680
- this.#allowedSchemes.add(loweredScheme);
685
+ this.#allowedSchemes.add(normalizedScheme);
681
686
  return [...this.#allowedSchemes];
682
687
  }
683
688
 
@@ -690,7 +695,8 @@ class URLSanitizer extends URISchemes {
690
695
  if (!isString(scheme)) {
691
696
  return false;
692
697
  }
693
- return this.#allowedSchemes.delete(scheme.trim().toLowerCase());
698
+ const normalizedScheme = this.normalize(scheme, true);
699
+ return this.#allowedSchemes.delete(normalizedScheme);
694
700
  }
695
701
 
696
702
  /**
@@ -707,13 +713,159 @@ class URLSanitizer extends URISchemes {
707
713
  * @param {Set<string>} [schemes] - The set of allowed schemes.
708
714
  * @returns {boolean} True if the URI is syntactically valid and permitted.
709
715
  */
710
- verify(uri, schemes = this.#allowedSchemes) {
711
- return super.verify(uri, schemes);
716
+ verify(uri, schemes) {
717
+ return super.verify(uri, schemes ?? this.#allowedSchemes);
712
718
  }
713
719
  }
714
- export { URLSanitizer };
715
720
 
716
- /* instance */
721
+ /* blob handlers */
722
+ /**
723
+ * Converts Blob to data URL from Buffer.
724
+ * @private
725
+ * @param {Blob} blob - The target Blob object.
726
+ * @returns {Promise<string|null>} A promise resolving to the data URL, or null.
727
+ */
728
+ const convertFromBuffer = async blob => {
729
+ const mimeStr = blob.type ? `${blob.type};base64` : 'base64';
730
+ const buffer = await blob.arrayBuffer();
731
+ const base64 = globalThis.Buffer.from(buffer).toString('base64');
732
+ return `data:${mimeStr},${base64}`;
733
+ };
734
+
735
+ /**
736
+ * Converts Blob to data URL from FileReader.
737
+ * @private
738
+ * @param {Blob} blob - The target Blob object.
739
+ * @returns {Promise<string|null>} A promise resolving to the data URL, or null.
740
+ */
741
+ const convertFromFileReader = blob =>
742
+ new Promise((resolve, reject) => {
743
+ const reader = new globalThis.FileReader();
744
+ reader.addEventListener('error', () => {
745
+ const error =
746
+ reader.error ||
747
+ new DOMException(
748
+ 'Failed to read Blob via FileReader.',
749
+ 'NotReadableError'
750
+ );
751
+ reject(error);
752
+ });
753
+ reader.addEventListener('abort', () => resolve(null));
754
+ reader.addEventListener('load', () => resolve(reader.result));
755
+ reader.readAsDataURL(blob);
756
+ });
757
+
758
+ /**
759
+ * Converts Blob to data URL from btoa.
760
+ * @private
761
+ * @param {Blob} blob - The target Blob object.
762
+ * @returns {Promise<string|null>} A promise resolving to the data URL, or null.
763
+ */
764
+ const convertFromBtoa = async blob => {
765
+ const mimeStr = blob.type ? `${blob.type};base64` : 'base64';
766
+ const buffer = await blob.arrayBuffer();
767
+ const uint8arr = new Uint8Array(buffer);
768
+ const chunks = [];
769
+ for (let i = 0; i < uint8arr.length; i += CHUNK_SIZE) {
770
+ chunks.push(String.fromCharCode(...uint8arr.subarray(i, i + CHUNK_SIZE)));
771
+ }
772
+ return `data:${mimeStr},${btoa(chunks.join(''))}`;
773
+ };
774
+
775
+ /**
776
+ * Reads a stream in chunks and validates its size.
777
+ * @private
778
+ * @param {ReadableStreamDefaultReader} reader - The stream reader.
779
+ * @param {number} maxSize - The maximum allowed size in bytes.
780
+ * @param {Uint8Array[]} chunks - The array to store chunks.
781
+ * @param {number} [accumulatedSize] - The currently accumulated size.
782
+ * @returns {Promise<void>}
783
+ */
784
+ const readStreamInChunks = async (
785
+ reader,
786
+ maxSize,
787
+ chunks,
788
+ accumulatedSize = 0
789
+ ) => {
790
+ const { done, value } = await reader.read();
791
+ if (done) {
792
+ return;
793
+ }
794
+ const newSize = accumulatedSize + value.byteLength;
795
+ if (newSize > maxSize) {
796
+ await reader.cancel('Size limit exceeded');
797
+ const msg = `Payload (${newSize} bytes) exceeds max (${maxSize} bytes).`;
798
+ throw new DOMException(msg, 'NotReadableError');
799
+ }
800
+ chunks.push(value);
801
+ return readStreamInChunks(reader, maxSize, chunks, newSize);
802
+ };
803
+
804
+ /**
805
+ * Fetches a blob URL and converts it to a data URL.
806
+ * @param {string} url - The blob URL to fetch.
807
+ * @param {number} [maxBlobSize] - The maximum allowed blob size in bytes.
808
+ * @returns {Promise<string>} A promise resolving to the data URL.
809
+ */
810
+ const fetchBlobAsDataURL = async (url, maxBlobSize) => {
811
+ let maxSize = MAX_BLOB_SIZE;
812
+ if (Number.isInteger(maxBlobSize) && maxBlobSize > 0) {
813
+ maxSize = maxBlobSize;
814
+ }
815
+ const response = await fetch(url);
816
+ if (!response.ok) {
817
+ const truncatedUrl = truncateURL(url);
818
+ let msg = `Failed to fetch ${truncatedUrl}`;
819
+ if (Number.isInteger(response.status)) {
820
+ if (response.statusText) {
821
+ msg += `: ${response.status} ${response.statusText}`;
822
+ } else {
823
+ msg += `: ${response.status}`;
824
+ }
825
+ }
826
+ throw new Error(msg);
827
+ }
828
+ // Check content length if available.
829
+ const contentLength = response.headers.get('content-length');
830
+ if (contentLength) {
831
+ const parsedLength = Number.parseInt(contentLength, DECI);
832
+ if (Number.isInteger(parsedLength) && parsedLength > maxSize) {
833
+ const msg = `Payload (${parsedLength} bytes) exceeds max (${maxSize} bytes).`;
834
+ throw new DOMException(msg, 'NotReadableError');
835
+ }
836
+ }
837
+ let blob;
838
+ // Use ReadableStream.
839
+ if (response.body) {
840
+ const reader = response.body.getReader();
841
+ const chunks = [];
842
+ try {
843
+ await readStreamInChunks(reader, maxSize, chunks, 0);
844
+ } finally {
845
+ reader.releaseLock();
846
+ }
847
+ const type = response.headers.get('content-type');
848
+ if (type) {
849
+ blob = new Blob(chunks, { type });
850
+ } else {
851
+ blob = new Blob(chunks);
852
+ }
853
+ } else {
854
+ blob = await response.blob();
855
+ if (blob.size > maxSize) {
856
+ const msg = `Payload (${blob.size} bytes) exceeds max (${maxSize} bytes).`;
857
+ throw new DOMException(msg, 'NotReadableError');
858
+ }
859
+ }
860
+ if (IS_NODE && globalThis.Buffer) {
861
+ return convertFromBuffer(blob);
862
+ } else if (globalThis.FileReader) {
863
+ return convertFromFileReader(blob);
864
+ }
865
+ return convertFromBtoa(blob);
866
+ };
867
+
868
+ /* URLSanitizer instance */
717
869
  const urlSanitizer = new URLSanitizer();
718
870
 
719
871
  /**
@@ -729,77 +881,62 @@ const urlSanitizer = new URLSanitizer();
729
881
  * @param {boolean} [opt.debug] - Enable debug mode.
730
882
  * @param {boolean} [opt.revokeObjectURL] - Revokes the blob URL after sanitization.
731
883
  * @param {number} [opt.maxBlobSize] - The maximum allowed blob size in bytes.
732
- * @param {number} [opt.maxLength] - The maximum allowed URL length
884
+ * @param {number} [opt.maxLength] - The maximum allowed URL length.
733
885
  * @returns {Promise<string|null>} A promise resolving to the sanitized URL, or null.
734
886
  */
735
- export const sanitizeURL = async (
736
- url,
737
- opt = {
738
- allow: [],
739
- deny: [],
740
- only: [],
741
- allowRelative: false,
742
- debug: false,
743
- revokeObjectURL: false,
744
- maxBlobSize: MAX_BLOB_SIZE
887
+ export const sanitizeURL = async (url, opt = {}) => {
888
+ if (!url || !isString(url)) {
889
+ return null;
745
890
  }
746
- ) => {
747
- const isDebug = !!opt?.debug;
748
- const maxBlobSize =
749
- Number.isInteger(opt?.maxBlobSize) && opt.maxBlobSize > 0
750
- ? opt.maxBlobSize
751
- : MAX_BLOB_SIZE;
752
- let res;
753
- if (url && isString(url)) {
754
- let scheme;
755
- const parsedUrl = URL.parse(url);
756
- if (parsedUrl) {
757
- scheme = parsedUrl.protocol.replace(/:$/, '');
758
- } else {
759
- logDebug(isDebug, `Invalid URL input format: ${url}`);
760
- }
761
- if (scheme === 'blob') {
762
- const { allow, deny, only } = opt;
763
- if (
764
- (Array.isArray(allow) &&
765
- allow.includes('blob') &&
766
- !(Array.isArray(deny) && deny.includes('blob'))) ||
767
- (Array.isArray(only) && only.includes('blob'))
768
- ) {
769
- let data;
770
- try {
771
- data = await fetch(url)
772
- .then(r => r.blob())
773
- .then(b => convertBlobToDataURL(b, maxBlobSize));
774
- } catch (e) {
775
- const msg = `Failed to fetch and convert blob URL: ${url}`;
776
- logDebug(isDebug, msg, e);
891
+ const options = {
892
+ ...DEFAULT_OPTS,
893
+ ...opt
894
+ };
895
+ const scheme = urlSanitizer.getScheme(url);
896
+ if (scheme === null) {
897
+ if (options.allowRelative) {
898
+ return urlSanitizer.sanitize(url, options);
899
+ }
900
+ return null;
901
+ }
902
+ if (scheme === 'blob') {
903
+ const { allow, deny, only } = options;
904
+ let res = null;
905
+ if (
906
+ (allow.includes('blob') && !deny.includes('blob')) ||
907
+ only.includes('blob')
908
+ ) {
909
+ let data;
910
+ try {
911
+ data = await fetchBlobAsDataURL(url, options.maxBlobSize);
912
+ } catch (e) {
913
+ if (options.debug) {
914
+ logDebug(
915
+ `Failed to fetch and convert blob URL: ${truncateURL(url)}`,
916
+ e
917
+ );
777
918
  }
778
- if (data) {
779
- const options = { ...opt };
780
- if (Array.isArray(opt.only)) {
781
- options.only = opt.only.includes('data')
782
- ? opt.only
783
- : [...opt.only, 'data'];
784
- } else if (Array.isArray(opt.allow)) {
785
- options.allow = opt.allow.includes('data')
786
- ? opt.allow
787
- : [...opt.allow, 'data'];
788
- if (Array.isArray(opt.deny)) {
789
- options.deny = opt.deny.filter(scheme => scheme !== 'data');
790
- }
919
+ }
920
+ if (data) {
921
+ if (only.length) {
922
+ if (!only.includes('data')) {
923
+ options.only = [...only, 'data'];
924
+ }
925
+ } else {
926
+ if (!allow.includes('data')) {
927
+ options.allow = [...allow, 'data'];
791
928
  }
792
- res = urlSanitizer.sanitize(data, options);
929
+ options.deny = options.deny.filter(s => s !== 'data');
793
930
  }
931
+ res = urlSanitizer.sanitize(data, options);
794
932
  }
795
- if (opt?.revokeObjectURL) {
796
- URL.revokeObjectURL(url);
797
- }
798
- } else if (scheme || opt.allowRelative) {
799
- res = urlSanitizer.sanitize(url, opt);
800
933
  }
934
+ if (options.revokeObjectURL) {
935
+ URL.revokeObjectURL(url);
936
+ }
937
+ return res;
801
938
  }
802
- return res || null;
939
+ return urlSanitizer.sanitize(url, options);
803
940
  };
804
941
 
805
942
  /**
@@ -817,86 +954,66 @@ export const sanitizeURL = async (
817
954
  * @param {number} [opt.maxLength] - The maximum allowed URL length.
818
955
  * @returns {string|null} The sanitized URL, or null if denied.
819
956
  */
820
- export const sanitizeURLSync = (
821
- url,
822
- opt = {
823
- allow: [],
824
- deny: [],
825
- only: [],
826
- allowRelative: false,
827
- debug: false,
828
- revokeObjectURL: false
957
+ export const sanitizeURLSync = (url, opt = {}) => {
958
+ if (!url || !isString(url)) {
959
+ return null;
829
960
  }
830
- ) => {
831
- const isDebug = !!opt?.debug;
832
- let res;
833
- if (url && isString(url)) {
834
- let scheme;
835
- const parsedUrl = URL.parse(url);
836
- if (parsedUrl) {
837
- scheme = parsedUrl.protocol.replace(/:$/, '');
838
- } else {
839
- logDebug(isDebug, `Invalid URL input format: ${url}`);
961
+ const options = {
962
+ ...DEFAULT_OPTS,
963
+ ...opt
964
+ };
965
+ const scheme = urlSanitizer.getScheme(url);
966
+ if (scheme === null) {
967
+ if (options.allowRelative) {
968
+ return urlSanitizer.sanitize(url, options);
840
969
  }
841
- if (scheme === 'blob') {
842
- if (opt?.revokeObjectURL) {
843
- URL.revokeObjectURL(url);
844
- }
845
- return null;
846
- } else if (scheme || opt.allowRelative) {
847
- res = urlSanitizer.sanitize(url, opt);
970
+ return null;
971
+ }
972
+ if (scheme === 'blob') {
973
+ if (options.revokeObjectURL) {
974
+ URL.revokeObjectURL(url);
848
975
  }
976
+ return null;
849
977
  }
850
- return res || null;
978
+ return urlSanitizer.sanitize(url, options);
851
979
  };
852
980
 
853
981
  /**
854
- * Inspects, parses, and sanitizes the given URL asynchronously.
982
+ * Sanitizes the given URL and returns its parsed components.
983
+ * NOTE: blob URLs are not revoked after inspection.
855
984
  * @param {string} url - The URL string to inspect.
856
985
  * @returns {Promise<InspectedURLResult>} A promise resolving to the inspected URL result.
857
986
  */
858
987
  export const inspectURL = async url => {
859
- const res = urlSanitizer.inspect(url);
860
- return res;
988
+ if (isString(url)) {
989
+ const parsedUrl = urlSanitizer.parse(url);
990
+ if (parsedUrl?.protocol === 'blob:') {
991
+ try {
992
+ const dataUrl = await fetchBlobAsDataURL(parsedUrl.href);
993
+ const inspectedURLResult = urlSanitizer.inspect(dataUrl);
994
+ inspectedURLResult.input = url;
995
+ return inspectedURLResult;
996
+ } catch (e) {
997
+ return { input: url, valid: false, reason: e.message };
998
+ }
999
+ }
1000
+ }
1001
+ return urlSanitizer.inspect(url);
861
1002
  };
862
1003
 
863
1004
  /**
864
- * @deprecated Use {@link inspectURL} instead.
865
- * @param {string} url - The URL string to inspect.
866
- * @returns {Promise<InspectedURLResult>} A promise resolving to the inspected URL result.
867
- */
868
- export const parseURL = async url => inspectURL(url);
869
-
870
- /**
871
- * Inspects, parses, and sanitizes the given URL synchronously.
872
- * @param {string} url - The URL string to inspect.
873
- * @returns {InspectedURLResult} The inspected URL result.
874
- */
875
- export const inspectURLSync = url => urlSanitizer.inspect(url);
876
-
877
- /**
878
- * @deprecated Use {@link inspectURLSync} instead.
879
- * @param {string} url - The URL string to inspect.
880
- * @returns {InspectedURLResult} The inspected URL result.
881
- */
882
- export const parseURLSync = url => inspectURLSync(url);
883
-
884
- /**
885
- * Asynchronously checks if the given string is a valid URI and is registered.
1005
+ * Checks if the given string is a valid URI and is registered.
886
1006
  * @param {string} uri - The URI string to verify.
887
- * @returns {Promise<boolean>} True if valid and registered, false otherwise.
1007
+ * @returns {boolean} True if valid and registered, false otherwise.
888
1008
  */
889
- export const isURI = async uri => {
890
- const res = urlSanitizer.verify(uri);
891
- return res;
892
- };
1009
+ export const isValidURI = uri => urlSanitizer.verify(uri);
893
1010
 
894
1011
  /**
895
- * Synchronously checks if the given string is a valid URI and is registered.
1012
+ * @deprecated Use {@link isValidURI} instead.
896
1013
  * @param {string} uri - The URI string to verify.
897
1014
  * @returns {boolean} True if valid and registered, false otherwise.
898
1015
  */
899
- export const isURISync = uri => urlSanitizer.verify(uri);
1016
+ export const isURISync = uri => isValidURI(uri);
900
1017
 
901
1018
  /* export instance */
902
1019
  export default urlSanitizer;