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.
- package/README.md +45 -48
- package/dist/url-sanitizer-wo-dompurify.min.js +1 -1
- package/dist/url-sanitizer-wo-dompurify.min.js.map +3 -3
- package/dist/url-sanitizer.min.js +4 -4
- package/dist/url-sanitizer.min.js.map +3 -3
- package/package.json +16 -19
- package/src/index.js +2 -5
- package/src/lib/iana/uri-schemes.json +1 -1
- package/src/mjs/constant.js +18 -0
- package/src/mjs/regexp.js +17 -11
- package/src/mjs/sanitizer.js +476 -319
- package/src/mjs/text-chars.js +6 -6
- package/src/mjs/uri-util.js +195 -139
- package/types/index.d.ts +1 -1
- package/types/mjs/constant.d.ts +3 -0
- package/types/mjs/regexp.d.ts +3 -2
- package/types/mjs/sanitizer.d.ts +15 -8
- package/types/mjs/text-chars.d.ts +1 -1
- package/types/mjs/uri-util.d.ts +9 -3
package/src/mjs/sanitizer.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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 = (
|
|
85
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
250
|
+
const normalizedScheme = this.normalize(item, true);
|
|
251
|
+
if (REG_SCRIPT_OR_BLOB.test(normalizedScheme)) {
|
|
190
252
|
return false;
|
|
191
253
|
}
|
|
192
|
-
const schemeParts =
|
|
254
|
+
const schemeParts = normalizedScheme.split('+');
|
|
193
255
|
const isScript = schemeParts.some(s => REG_SCRIPT.test(s));
|
|
194
|
-
if (isScript || !REG_SCHEME.test(
|
|
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(
|
|
200
|
-
allowedSchemes.add(
|
|
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 {
|
|
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 -
|
|
242
|
-
* @param {
|
|
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,
|
|
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(
|
|
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,
|
|
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 {
|
|
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 (
|
|
387
|
+
if (Array.isArray(deny) && deny.length) {
|
|
388
|
+
for (const item of deny) {
|
|
331
389
|
if (isString(item)) {
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
schemeMap.set(
|
|
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 -
|
|
405
|
+
* @param {boolean} allowRelative - Allow relative URLs.
|
|
348
406
|
* @param {Set<string>} allowedSchemes - Permitted schemes.
|
|
349
|
-
* @param {
|
|
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
|
-
|
|
411
|
+
const urlObj = this.parse(url);
|
|
412
|
+
let isVerified = this.verifyParsed(urlObj, allowedSchemes);
|
|
354
413
|
let isRelative = false;
|
|
355
|
-
let
|
|
414
|
+
let relativePath = '';
|
|
356
415
|
// Handle Relative URLs
|
|
357
416
|
if (!isVerified && allowRelative && !REG_VERIFY_RELATIVE.test(url)) {
|
|
358
|
-
const
|
|
359
|
-
|
|
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
|
-
|
|
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:
|
|
440
|
+
urlToSanitize: relativePath
|
|
387
441
|
};
|
|
388
442
|
}
|
|
389
|
-
const
|
|
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:
|
|
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 {
|
|
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
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
463
|
-
if (dataSchemeParts.some(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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
*
|
|
557
|
+
* Executes the core sanitization logic.
|
|
558
|
+
* @private
|
|
504
559
|
* @param {string} url - The URL string to sanitize.
|
|
505
|
-
* @param {object}
|
|
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
|
-
|
|
563
|
+
#executeSanitize(url, opt) {
|
|
516
564
|
if (!url || !isString(url)) {
|
|
517
565
|
return null;
|
|
518
566
|
}
|
|
519
|
-
const maxLength =
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
527
|
-
(
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
537
|
-
if (
|
|
538
|
-
return
|
|
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
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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.#
|
|
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
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
}
|
|
604
|
-
invalidReason =
|
|
644
|
+
} catch (e) {
|
|
645
|
+
invalidReason = e.message;
|
|
605
646
|
}
|
|
606
647
|
if (sanitizedUrl) {
|
|
607
|
-
|
|
608
|
-
const
|
|
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 (
|
|
613
|
-
const
|
|
614
|
-
const
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
703
|
+
const normalizedScheme = this.normalize(scheme, true);
|
|
704
|
+
if (normalizedScheme) {
|
|
705
|
+
return this.#allowedSchemes.has(normalizedScheme);
|
|
659
706
|
}
|
|
660
|
-
return
|
|
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
|
-
|
|
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
|
|
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(
|
|
722
|
+
if (isScript || !REG_SCHEME.test(normalizedScheme)) {
|
|
678
723
|
throw new Error(`Invalid scheme: ${scheme}`);
|
|
679
724
|
}
|
|
680
|
-
this.#allowedSchemes.add(
|
|
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
|
-
|
|
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
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
if (
|
|
837
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
|
1018
|
+
return urlSanitizer.sanitize(url, options);
|
|
851
1019
|
};
|
|
852
1020
|
|
|
853
1021
|
/**
|
|
854
|
-
*
|
|
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
|
-
|
|
860
|
-
|
|
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
|
-
*
|
|
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 {
|
|
1047
|
+
* @returns {boolean} True if valid and registered, false otherwise.
|
|
888
1048
|
*/
|
|
889
|
-
export const
|
|
890
|
-
const res = urlSanitizer.verify(uri);
|
|
891
|
-
return res;
|
|
892
|
-
};
|
|
1049
|
+
export const isValidURI = uri => urlSanitizer.verify(uri);
|
|
893
1050
|
|
|
894
1051
|
/**
|
|
895
|
-
*
|
|
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 =>
|
|
1056
|
+
export const isURISync = uri => isValidURI(uri);
|
|
900
1057
|
|
|
901
1058
|
/* export instance */
|
|
902
1059
|
export default urlSanitizer;
|