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