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