url-sanitizer 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@ Sanitize the given URL.
45
45
  * `opt.deny` **[Array][4]<[string][1]>** Array of denied schemes, e.g. `['web+foo']`.
46
46
  * `opt.only` **[Array][4]<[string][1]>** Array of specific schemes to allow, e.g. `['git', 'https']`.
47
47
  `only` takes precedence over `allow` and `deny`.
48
- * `opt.truncate` **[boolean][2]** Truncate strings matching tags and quotes.
48
+ * `opt.remove` **[boolean][2]** Remove tag or quote and the rest following it.
49
49
 
50
50
  Returns **[Promise][5]<[string][1]?>** Sanitized URL, `null`able.
51
51
 
@@ -76,12 +76,12 @@ const res3 = await sanitizeURL(`data:text/html;base64,${base64data3}`, {
76
76
  console.log(decodeURIComponent(res3));
77
77
  // => 'data:text/html,<div></div>'
78
78
 
79
- const base64data4 = btoa('<div><iframe src="javascript:alert(1)"></iframe></div>');
79
+ const base64data4 = btoa('<div><img src="javascript:alert(1)"></div>');
80
80
  const res4 = await sanitizeURL(`data:text/html;base64,${base64data4}`);
81
- // => 'data:text/html,%3Cdiv%3E%3C%2Fdiv%3E'
81
+ // => 'data:text/html,%3Cdiv%3E%3Cimg%3E%3C%2Fdiv%3E'
82
82
 
83
83
  console.log(decodeURIComponent(res4));
84
- // => 'data:text/html,<div></div>'
84
+ // => 'data:text/html,<div><img></div>'
85
85
 
86
86
  // Deny if the scheme matches the `deny` list
87
87
  const res5 = await sanitizeURL('web+foo://example.com', {
@@ -112,14 +112,14 @@ const res8 = await sanitizeURL('git+https://example.com/foo.git?<script>alert(1)
112
112
  console.log(decodeURIComponent(res8));
113
113
  // => 'git+https://example.com/foo.git?&lt;script&gt;alert(1)&lt;/script&gt;'
114
114
 
115
- // Truncate the tag or quote and the rest of the URL following it.
115
+ // Remove the tag or quote and the rest of the URL following it.
116
116
  const res9 = await sanitizeURL('https://example.com/" onclick="alert(1)"', {
117
- truncate: true
117
+ remove: true
118
118
  });
119
119
  // => 'https://example.com/'
120
120
 
121
121
  const res10 = await sanitizeURL('https://example.com/?<script>alert(1)</script>', {
122
- truncate: true
122
+ remove: true
123
123
  });
124
124
  // => 'https://example.com/?'
125
125
  ```
package/package.json CHANGED
@@ -48,7 +48,7 @@
48
48
  "eslint": "^8.33.0",
49
49
  "eslint-config-standard": "^17.0.0",
50
50
  "eslint-plugin-import": "^2.27.5",
51
- "eslint-plugin-jsdoc": "^39.7.5",
51
+ "eslint-plugin-jsdoc": "^39.8.0",
52
52
  "eslint-plugin-n": "^15.6.1",
53
53
  "eslint-plugin-node": "^11.1.0",
54
54
  "eslint-plugin-promise": "^6.1.1",
@@ -58,5 +58,5 @@
58
58
  "sinon": "^15.0.1",
59
59
  "undici": "^5.16.0"
60
60
  },
61
- "version": "0.7.2"
61
+ "version": "0.7.3"
62
62
  }
@@ -24,7 +24,6 @@ const REG_SCHEME_CUSTOM = /^(?:ext|web)\+[a-z]+$/;
24
24
  const REG_SCRIPT = /(?:java|vb)script/;
25
25
  const REG_URL_ENC = /^%[\dA-F]{2}$/i;
26
26
  const REG_URL_ENC_AMP = /%26/g;
27
- const REG_URL_ENC_HTML_SP = /%26(?:(?:l|g|quo)t|%2339);?/g;
28
27
 
29
28
  /**
30
29
  * get URL encoded string
@@ -166,19 +165,6 @@ export const parseURLEncodedNumCharRef = (str, nest = 0) => {
166
165
  return res;
167
166
  };
168
167
 
169
- /**
170
- * purify URL encoded DOM
171
- *
172
- * @param {string} dom - DOM input
173
- * @returns {string} - purified DOM
174
- */
175
- export const purifyURLEncodedDOM = dom => {
176
- if (!isString(dom)) {
177
- throw new TypeError(`Expected String but got ${getType(dom)}.`);
178
- }
179
- return encodeURIComponent(domPurify.sanitize(decodeURIComponent(dom)));
180
- };
181
-
182
168
  /**
183
169
  * URI schemes
184
170
  *
@@ -281,6 +267,45 @@ export class URLSanitizer extends URISchemes {
281
267
  this.#recurse = new Set();
282
268
  }
283
269
 
270
+ /**
271
+ * purify URL encoded DOM
272
+ *
273
+ * @param {string} dom - DOM input
274
+ * @returns {string} - purified DOM
275
+ */
276
+ purify(dom) {
277
+ if (!isString(dom)) {
278
+ throw new TypeError(`Expected String but got ${getType(dom)}.`);
279
+ }
280
+ let purifiedDom = domPurify.sanitize(decodeURIComponent(dom));
281
+ if (purifiedDom) {
282
+ const matchedDataUrls = purifiedDom.matchAll(REG_DATA_URL);
283
+ const items = [...matchedDataUrls].reverse();
284
+ for (const item of items) {
285
+ let [dataUrl] = item;
286
+ if (REG_DATA_URL_BASE64.test(dataUrl)) {
287
+ [dataUrl] = REG_DATA_URL_BASE64.exec(dataUrl);
288
+ }
289
+ this.#nest++;
290
+ this.#recurse.add(dataUrl);
291
+ const parsedDataUrl = this.sanitize(dataUrl, {
292
+ allow: ['data']
293
+ });
294
+ const { index } = item;
295
+ const [preDataUrl, postDataUrl] = [
296
+ purifiedDom.substring(0, index),
297
+ purifiedDom.substring(index + dataUrl.length)
298
+ ];
299
+ if (parsedDataUrl) {
300
+ purifiedDom = `${preDataUrl}${parsedDataUrl}${postDataUrl}`;
301
+ } else {
302
+ purifiedDom = `${preDataUrl}${postDataUrl}`;
303
+ }
304
+ }
305
+ }
306
+ return encodeURIComponent(purifiedDom);
307
+ };
308
+
284
309
  /**
285
310
  * sanitize URL
286
311
  * NOTE: `data` and/or `file` schemes must be explicitly allowed
@@ -291,7 +316,7 @@ export class URLSanitizer extends URISchemes {
291
316
  * @param {Array.<string>} opt.allow - array of allowed schemes
292
317
  * @param {Array.<string>} opt.deny - array of denied schemes
293
318
  * @param {Array.<string>} opt.only - array of specific schemes to allow
294
- * @param {boolean} opt.truncate - truncate tag and/or quote and the rest
319
+ * @param {boolean} opt.remove - remove tag and/or quote and the rest
295
320
  * @returns {?string} - sanitized URL
296
321
  */
297
322
  sanitize(url, opt = { allow: [], deny: [], only: [] }) {
@@ -299,7 +324,7 @@ export class URLSanitizer extends URISchemes {
299
324
  this.#nest = 0;
300
325
  throw new Error('Data URLs nested too deeply.');
301
326
  }
302
- const { allow, deny, only, truncate } = opt ?? {};
327
+ const { allow, deny, only, remove } = opt ?? {};
303
328
  const schemeMap = new Map([
304
329
  ['data', false],
305
330
  ['file', false],
@@ -393,9 +418,9 @@ export class URLSanitizer extends URISchemes {
393
418
  const [head, ...body] = pathname.split(',');
394
419
  const data = `${body.join(',')}${search}${hash}`;
395
420
  const mediaType = head.split(';');
421
+ const isBase64 = mediaType[mediaType.length - 1] === 'base64';
396
422
  let parsedData = data;
397
- if (mediaType[mediaType.length - 1] === 'base64') {
398
- mediaType.pop();
423
+ if (isBase64) {
399
424
  parsedData = parseBase64(data);
400
425
  } else {
401
426
  try {
@@ -443,16 +468,27 @@ export class URLSanitizer extends URISchemes {
443
468
  } else {
444
469
  escapeHtml = true;
445
470
  }
446
- urlToSanitize = `${scheme}:${mediaType.join(';')},${parsedData}`;
447
471
  } else if (this.#recurse.has(url)) {
448
472
  this.#recurse.delete(url);
449
473
  } else {
450
474
  escapeHtml = true;
451
475
  }
476
+ if (REG_MIME_DOM.test(head)) {
477
+ parsedData =
478
+ this.purify(parsedData).replace(/%23$/, '').replace(/%3F$/, '');
479
+ }
480
+ if (urlToSanitize && parsedData) {
481
+ if (isBase64 && parsedData !== data) {
482
+ mediaType.pop();
483
+ }
484
+ urlToSanitize = `${scheme}:${mediaType.join(';')},${parsedData}`;
485
+ } else {
486
+ urlToSanitize = '';
487
+ }
452
488
  } else {
453
489
  escapeHtml = true;
454
490
  }
455
- if (!schemeParts.includes('data') && truncate &&
491
+ if (!schemeParts.includes('data') && remove &&
456
492
  REG_HTML_SP_URL_ENC_SHORT.test(urlToSanitize)) {
457
493
  const item = REG_HTML_SP_URL_ENC_SHORT.exec(urlToSanitize);
458
494
  const { index } = item;
@@ -463,8 +499,10 @@ export class URLSanitizer extends URISchemes {
463
499
  .replace(REG_HTML_SP, getURLEncodedString)
464
500
  .replace(REG_URL_ENC_AMP, escapeURLEncodedHTMLChars);
465
501
  if (escapeHtml) {
466
- sanitizedUrl = sanitizedUrl
467
- .replace(REG_HTML_SP_URL_ENC, escapeURLEncodedHTMLChars);
502
+ if (!schemeParts.includes('data')) {
503
+ sanitizedUrl = sanitizedUrl
504
+ .replace(REG_HTML_SP_URL_ENC, escapeURLEncodedHTMLChars);
505
+ }
468
506
  this.#nest = 0;
469
507
  }
470
508
  } else {
@@ -533,51 +571,15 @@ export class URLSanitizer extends URISchemes {
533
571
  }
534
572
  dataUrl.set('mime', mediaType.join(';'));
535
573
  dataUrl.set('base64', isBase64);
536
- let purifiedDom;
537
- if (!isBase64 && REG_MIME_DOM.test(head)) {
538
- let parsedData = data;
539
- const matchedHtmlChars = parsedData.matchAll(REG_URL_ENC_HTML_SP);
540
- const items = [...matchedHtmlChars].reverse();
541
- for (const item of items) {
542
- const [htmlChar] = item;
543
- const { index } = item;
544
- const [preHtmlChar, postHtmlChar] = [
545
- parsedData.substring(0, index),
546
- parsedData.substring(index + htmlChar.length)
547
- ];
548
- const unescapedHtmlChar = unescapeURLEncodedHTMLChars(htmlChar);
549
- parsedData = `${preHtmlChar}${unescapedHtmlChar}${postHtmlChar}`;
550
- }
551
- purifiedDom = purifyURLEncodedDOM(parsedData);
552
- dataUrl.set('data', purifiedDom);
553
- } else {
554
- dataUrl.set('data', data);
555
- }
574
+ dataUrl.set('data', data);
556
575
  parsedUrl.set('data', Object.fromEntries(dataUrl));
557
- for (const key in urlObj) {
558
- let value = urlObj[key];
559
- if (isString(value)) {
560
- if (isString(purifiedDom)) {
561
- switch (key) {
562
- case 'href':
563
- value = `${protocol}${mediaType.join(';')},${purifiedDom}`;
564
- break;
565
- case 'pathname':
566
- value = `${mediaType.join(';')},${purifiedDom}`;
567
- break;
568
- default:
569
- }
570
- }
571
- parsedUrl.set(key, value);
572
- }
573
- }
574
576
  } else {
575
577
  parsedUrl.set('data', null);
576
- for (const key in urlObj) {
577
- const value = urlObj[key];
578
- if (isString(value)) {
579
- parsedUrl.set(key, value);
580
- }
578
+ }
579
+ for (const key in urlObj) {
580
+ const value = urlObj[key];
581
+ if (isString(value)) {
582
+ parsedUrl.set(key, value);
581
583
  }
582
584
  }
583
585
  } else {