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 +7 -7
- package/package.json +2 -2
- package/src/mjs/uri-util.js +66 -64
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.
|
|
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><
|
|
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?<script>alert(1)</script>'
|
|
114
114
|
|
|
115
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
61
|
+
"version": "0.7.3"
|
|
62
62
|
}
|
package/src/mjs/uri-util.js
CHANGED
|
@@ -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.
|
|
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,
|
|
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 (
|
|
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') &&
|
|
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
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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 {
|