url-sanitizer 2.3.1 → 4.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 +166 -74
- package/dist/url-sanitizer-wo-dompurify.min.js +1 -1
- package/dist/url-sanitizer-wo-dompurify.min.js.map +4 -4
- package/dist/url-sanitizer.min.js +4 -4
- package/dist/url-sanitizer.min.js.map +4 -4
- package/package.json +16 -14
- package/src/lib/file/text-chars.json +1 -1
- package/src/mjs/common.js +7 -6
- package/src/mjs/constant.js +39 -19
- package/src/mjs/dompurify.js +6 -0
- package/src/mjs/file-reader.js +125 -68
- package/src/mjs/regexp.js +79 -0
- package/src/mjs/sanitizer.js +416 -249
- package/src/mjs/text-chars.js +68 -0
- package/src/mjs/uri-util.js +176 -172
- package/types/mjs/common.d.ts +2 -2
- package/types/mjs/constant.d.ts +5 -17
- package/types/mjs/dompurify.d.ts +1 -1
- package/types/mjs/file-reader.d.ts +10 -10
- package/types/mjs/regexp.d.ts +12 -0
- package/types/mjs/sanitizer.d.ts +26 -15
- package/types/mjs/text-chars.d.ts +3 -0
- package/types/mjs/uri-util.d.ts +9 -10
- package/src/mjs/lib-util.js +0 -37
- package/types/mjs/lib-util.d.ts +0 -3
package/src/mjs/sanitizer.js
CHANGED
|
@@ -6,41 +6,70 @@
|
|
|
6
6
|
import { domPurify } from './dompurify.js';
|
|
7
7
|
import { getType, isString } from './common.js';
|
|
8
8
|
import {
|
|
9
|
-
createDataURLFromBlob, escapeURLEncodedHTMLChars, parseBase64,
|
|
10
|
-
parseURLEncodedNumCharRef,
|
|
9
|
+
URISchemes, createDataURLFromBlob, escapeURLEncodedHTMLChars, parseBase64,
|
|
10
|
+
parseURLEncodedNumCharRef, trimTrailingEmptyQueryAndHash
|
|
11
11
|
} from './uri-util.js';
|
|
12
|
+
|
|
13
|
+
/* constants */
|
|
14
|
+
import { MAX_BLOB_SIZE, MAX_NEST } from './constant.js';
|
|
12
15
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from './
|
|
16
|
+
REG_MIME_DOM, REG_SCHEME, REG_SCRIPT, REG_SCRIPT_BLOB, REG_TAG_QUOT,
|
|
17
|
+
REG_VERIFY_RELATIVE
|
|
18
|
+
} from './regexp.js';
|
|
19
|
+
const URL_PROPS = Object.freeze([
|
|
20
|
+
'href',
|
|
21
|
+
'origin',
|
|
22
|
+
'protocol',
|
|
23
|
+
'username',
|
|
24
|
+
'password',
|
|
25
|
+
'host',
|
|
26
|
+
'hostname',
|
|
27
|
+
'port',
|
|
28
|
+
'pathname',
|
|
29
|
+
'search',
|
|
30
|
+
'hash'
|
|
31
|
+
]);
|
|
32
|
+
const INTERNAL_PURIFY_CONFIG = Object.freeze({
|
|
33
|
+
RETURN_DOM: false,
|
|
34
|
+
RETURN_DOM_FRAGMENT: false,
|
|
35
|
+
RETURN_TRUSTED_TYPE: false
|
|
36
|
+
});
|
|
16
37
|
|
|
17
38
|
/* typedef */
|
|
18
39
|
/**
|
|
19
|
-
*
|
|
20
|
-
* @
|
|
21
|
-
* @property {
|
|
22
|
-
* @property {
|
|
23
|
-
* @property {string}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* @
|
|
30
|
-
* @property {string}
|
|
31
|
-
* @property {
|
|
32
|
-
* @property {
|
|
33
|
-
* @property {string} [
|
|
34
|
-
* @property {string} [
|
|
35
|
-
* @property {string} [
|
|
36
|
-
* @property {string} [
|
|
40
|
+
* The parsed result of a data URL.
|
|
41
|
+
* @typedef {object} InspectedDataURL
|
|
42
|
+
* @property {string} mime - The MIME type of the data.
|
|
43
|
+
* @property {boolean} base64 - Indicates whether the data is base64-encoded.
|
|
44
|
+
* @property {string} data - The actual data part of the data URL.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The result of an inspected URL, extending the standard URL API.
|
|
49
|
+
* The properties except for input and valid are omitted from the object for invalid URLs.
|
|
50
|
+
* @typedef {object} InspectedURLResult
|
|
51
|
+
* @property {string} input - The original URL input.
|
|
52
|
+
* @property {boolean} valid - Indicates whether the URI is valid.
|
|
53
|
+
* @property {InspectedDataURL | null} [data] - The parsed result of a data URL. Null if not a data URL.
|
|
54
|
+
* @property {string} [href] - The sanitized URL input.
|
|
55
|
+
* @property {string} [origin] - The scheme, domain, and port.
|
|
56
|
+
* @property {string} [protocol] - The protocol scheme.
|
|
57
|
+
* @property {string} [username] - The specified username.
|
|
58
|
+
* @property {string} [password] - The specified password.
|
|
59
|
+
* @property {string} [host] - The domain and port.
|
|
60
|
+
* @property {string} [hostname] - The domain.
|
|
61
|
+
* @property {string} [port] - The port number.
|
|
62
|
+
* @property {string} [pathname] - The path.
|
|
63
|
+
* @property {string} [search] - The query string.
|
|
64
|
+
* @property {string} [hash] - The fragment identifier.
|
|
37
65
|
*/
|
|
38
66
|
|
|
39
67
|
/**
|
|
40
68
|
* Internal debug logger.
|
|
41
|
-
* @param {boolean} isDebug -
|
|
42
|
-
* @param {string} message -
|
|
43
|
-
* @param {Error} [error] -
|
|
69
|
+
* @param {boolean} isDebug - Flag to enable or disable debug output.
|
|
70
|
+
* @param {string} message - The message to output.
|
|
71
|
+
* @param {Error} [error] - The caught error object, if any.
|
|
72
|
+
* @returns {void}
|
|
44
73
|
*/
|
|
45
74
|
export const logDebug = (isDebug, message, error) => {
|
|
46
75
|
if (isDebug) {
|
|
@@ -53,180 +82,181 @@ export const logDebug = (isDebug, message, error) => {
|
|
|
53
82
|
*/
|
|
54
83
|
class URLSanitizer extends URISchemes {
|
|
55
84
|
/* private fields */
|
|
56
|
-
#
|
|
57
|
-
|
|
85
|
+
#allowedSchemes;
|
|
86
|
+
|
|
87
|
+
static #currentCtx = null;
|
|
88
|
+
|
|
89
|
+
/** @type {URLSanitizer | null} */
|
|
90
|
+
static #currentInstance = null;
|
|
58
91
|
|
|
59
92
|
/**
|
|
60
|
-
*
|
|
93
|
+
* DOMPurify 'uponSanitizeAttribute' hook callback.
|
|
94
|
+
* @private
|
|
95
|
+
* @static
|
|
96
|
+
* @param {Node} node - The DOM node being sanitized.
|
|
97
|
+
* @param {object} e - The event object containing attribute details.
|
|
61
98
|
*/
|
|
99
|
+
static #uponSanitizeAttribute(node, e) {
|
|
100
|
+
if (!e.attrValue || !/^\s*data:/i.test(e.attrValue)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
/** @type {URLSanitizer | null} */
|
|
104
|
+
const sanitizer = URLSanitizer.#currentInstance;
|
|
105
|
+
const ctx = URLSanitizer.#currentCtx;
|
|
106
|
+
if (!ctx || !sanitizer) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let urlObj;
|
|
110
|
+
try {
|
|
111
|
+
urlObj = new URL(e.attrValue);
|
|
112
|
+
} catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (urlObj.protocol === 'data:') {
|
|
116
|
+
const originalUrl = e.attrValue;
|
|
117
|
+
if (!ctx.recurse) {
|
|
118
|
+
ctx.recurse = new Set();
|
|
119
|
+
}
|
|
120
|
+
if (ctx.recurse.has(originalUrl)) {
|
|
121
|
+
const msg = `Circular Data URL detected and skipped: ${originalUrl}`;
|
|
122
|
+
logDebug(ctx.debug, msg);
|
|
123
|
+
e.attrValue = '';
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
ctx.nest++;
|
|
127
|
+
ctx.recurse.add(originalUrl);
|
|
128
|
+
try {
|
|
129
|
+
const sanitized = sanitizer.#process(originalUrl, {
|
|
130
|
+
allow: ['data'],
|
|
131
|
+
deny: [],
|
|
132
|
+
only: [],
|
|
133
|
+
allowRelative: false
|
|
134
|
+
}, ctx);
|
|
135
|
+
e.attrValue = sanitized || '';
|
|
136
|
+
} finally {
|
|
137
|
+
ctx.nest--;
|
|
138
|
+
ctx.recurse.delete(originalUrl);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
static {
|
|
144
|
+
domPurify.setConfig(INTERNAL_PURIFY_CONFIG);
|
|
145
|
+
domPurify.addHook(
|
|
146
|
+
'uponSanitizeAttribute',
|
|
147
|
+
URLSanitizer.#uponSanitizeAttribute
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
62
151
|
constructor() {
|
|
63
152
|
super();
|
|
64
|
-
this.#
|
|
153
|
+
this.#allowedSchemes = new Set(super.get());
|
|
65
154
|
}
|
|
66
155
|
|
|
67
156
|
/**
|
|
68
|
-
*
|
|
69
|
-
* @
|
|
70
|
-
* @
|
|
157
|
+
* Helper method to register schemes for the 'allow' or 'only' options.
|
|
158
|
+
* @private
|
|
159
|
+
* @param {string} item - The scheme to register.
|
|
160
|
+
* @param {string} listName - The name of the target option list.
|
|
161
|
+
* @param {Set<string>} allowedSchemes - The local set of allowed schemes.
|
|
162
|
+
* @param {Map<string, boolean>} schemeMap - The local map of schemes.
|
|
163
|
+
* @param {object} ctx - The context for state management.
|
|
164
|
+
* @returns {boolean} True if the scheme is successfully registered.
|
|
71
165
|
*/
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
166
|
+
#registerScheme(item, listName, allowedSchemes, schemeMap, ctx) {
|
|
167
|
+
if (REG_SCRIPT_BLOB.test(item)) {
|
|
168
|
+
return false;
|
|
75
169
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (REG_DATA_URL_B64.test(dataUrl)) {
|
|
83
|
-
[dataUrl] = REG_DATA_URL_B64.exec(dataUrl);
|
|
84
|
-
}
|
|
85
|
-
this.#nest++;
|
|
86
|
-
this.#recurse.add(dataUrl);
|
|
87
|
-
let parsedDataUrl;
|
|
88
|
-
try {
|
|
89
|
-
parsedDataUrl = this.sanitize(dataUrl, {
|
|
90
|
-
allow: ['data']
|
|
91
|
-
});
|
|
92
|
-
} finally {
|
|
93
|
-
this.#nest--;
|
|
94
|
-
this.#recurse.delete(dataUrl);
|
|
95
|
-
}
|
|
96
|
-
const { index } = item;
|
|
97
|
-
const [preDataUrl, postDataUrl] = [
|
|
98
|
-
replacedData.substring(0, index),
|
|
99
|
-
replacedData.substring(index + dataUrl.length)
|
|
100
|
-
];
|
|
101
|
-
if (parsedDataUrl) {
|
|
102
|
-
replacedData = `${preDataUrl}${parsedDataUrl}${postDataUrl}`;
|
|
103
|
-
} else {
|
|
104
|
-
replacedData = `${preDataUrl}${postDataUrl}`;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
170
|
+
const schemeParts = item.split('+');
|
|
171
|
+
const isScript = schemeParts.some(s => REG_SCRIPT.test(s));
|
|
172
|
+
if (isScript || !REG_SCHEME.test(item)) {
|
|
173
|
+
const msg = `Failed to add scheme '${item}' in '${listName}' list.`;
|
|
174
|
+
logDebug(ctx.debug, msg);
|
|
175
|
+
return false;
|
|
107
176
|
}
|
|
108
|
-
|
|
177
|
+
schemeMap.set(item, true);
|
|
178
|
+
allowedSchemes.add(item);
|
|
179
|
+
return true;
|
|
109
180
|
}
|
|
110
181
|
|
|
111
182
|
/**
|
|
112
|
-
*
|
|
113
|
-
* @
|
|
114
|
-
* @
|
|
183
|
+
* Purifies a URL-encoded DOM string to prevent XSS.
|
|
184
|
+
* @private
|
|
185
|
+
* @param {string} dom - The URL-encoded DOM string.
|
|
186
|
+
* @param {object} ctx - The context for state management.
|
|
187
|
+
* @returns {string} The purified DOM string.
|
|
115
188
|
*/
|
|
116
|
-
purify(dom) {
|
|
117
|
-
if (!isString(dom)) {
|
|
118
|
-
throw new TypeError(`Expected String but got ${getType(dom)}.`);
|
|
119
|
-
}
|
|
189
|
+
#purify(dom, ctx) {
|
|
120
190
|
let decodedDom = dom;
|
|
121
191
|
try {
|
|
122
|
-
// prevent URIError caused by malformed percent-encoding
|
|
123
|
-
// e.g., a standalone '%'
|
|
124
192
|
decodedDom = decodeURIComponent(dom);
|
|
125
|
-
} catch
|
|
193
|
+
} catch {
|
|
126
194
|
// fall through
|
|
127
195
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
196
|
+
const prevInstance = URLSanitizer.#currentInstance;
|
|
197
|
+
const prevCtx = URLSanitizer.#currentCtx;
|
|
198
|
+
URLSanitizer.#currentInstance = this;
|
|
199
|
+
URLSanitizer.#currentCtx = ctx;
|
|
200
|
+
let purifiedDom;
|
|
201
|
+
try {
|
|
202
|
+
purifiedDom = ctx.domPurify.sanitize(decodedDom);
|
|
203
|
+
} finally {
|
|
204
|
+
URLSanitizer.#currentInstance = prevInstance;
|
|
205
|
+
URLSanitizer.#currentCtx = prevCtx;
|
|
131
206
|
}
|
|
132
|
-
purifiedDom = purifiedDom
|
|
133
|
-
.replace(/(?<!(?:#|%23).*)(?:\?|%3F)$/, '');
|
|
207
|
+
purifiedDom = trimTrailingEmptyQueryAndHash(purifiedDom);
|
|
134
208
|
try {
|
|
135
|
-
// prevent URIError caused by lone surrogates
|
|
136
209
|
return encodeURI(purifiedDom);
|
|
137
|
-
} catch
|
|
210
|
+
} catch {
|
|
138
211
|
return purifiedDom;
|
|
139
212
|
}
|
|
140
213
|
}
|
|
141
214
|
|
|
142
215
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* @param {
|
|
148
|
-
* @
|
|
149
|
-
* @param {Array.<string>} [opt.allow] - array of allowed schemes
|
|
150
|
-
* @param {Array.<string>} [opt.deny] - array of denied schemes
|
|
151
|
-
* @param {Array.<string>} [opt.only] - array of specific schemes to allow
|
|
152
|
-
* @param {boolean} [opt.allowRelative] - allow relative URLs
|
|
153
|
-
* @param {boolean} [opt.debug] - enable debug mode
|
|
154
|
-
* @returns {?string} - sanitized URL
|
|
216
|
+
* Internal recursive method for sanitization.
|
|
217
|
+
* @private
|
|
218
|
+
* @param {string} url - The URL string to sanitize.
|
|
219
|
+
* @param {object} rules - Normalized sanitization rules.
|
|
220
|
+
* @param {object} ctx - Internal context for state management.
|
|
221
|
+
* @returns {string|null} The sanitized URL, or null.
|
|
155
222
|
*/
|
|
156
|
-
|
|
157
|
-
if (
|
|
223
|
+
#process(url, rules, ctx) {
|
|
224
|
+
if (ctx.nest > MAX_NEST) {
|
|
158
225
|
throw new Error('Data URLs nested too deeply.');
|
|
159
226
|
}
|
|
160
|
-
const {
|
|
161
|
-
|
|
162
|
-
} = opt ?? {};
|
|
163
|
-
const schemeMap = new Map([
|
|
164
|
-
['blob', false],
|
|
165
|
-
['data', false],
|
|
166
|
-
['file', false],
|
|
167
|
-
['javascript', false],
|
|
168
|
-
['vbscript', false]
|
|
169
|
-
]);
|
|
170
|
-
const tempScheme = new Set();
|
|
227
|
+
const { allow, deny, only, allowRelative } = rules;
|
|
228
|
+
let allowedSchemes = this.#allowedSchemes;
|
|
171
229
|
let restrictScheme = false;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
const items = Object.values(only);
|
|
178
|
-
for (let item of items) {
|
|
230
|
+
const schemeMap = new Map(ctx.schemeMap);
|
|
231
|
+
if (only.length) {
|
|
232
|
+
allowedSchemes = new Set();
|
|
233
|
+
for (let item of only) {
|
|
179
234
|
if (isString(item)) {
|
|
180
235
|
item = item.trim();
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
super.add(item);
|
|
187
|
-
} catch (e) {
|
|
188
|
-
const msg = `Failed to add scheme '${item}' in 'only' list.`;
|
|
189
|
-
logDebug(debug, msg, e);
|
|
190
|
-
}
|
|
191
|
-
if (super.has(item)) {
|
|
192
|
-
schemeMap.set(item, true);
|
|
193
|
-
tempScheme.add(item);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
if (!restrictScheme && schemeMap.has(item)) {
|
|
197
|
-
restrictScheme = schemeMap.get(item);
|
|
198
|
-
}
|
|
236
|
+
const registered =
|
|
237
|
+
this.#registerScheme(item, 'only', allowedSchemes, schemeMap, ctx);
|
|
238
|
+
if (registered && !restrictScheme) {
|
|
239
|
+
restrictScheme = true;
|
|
199
240
|
}
|
|
200
241
|
}
|
|
201
242
|
}
|
|
202
243
|
} else {
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
for (
|
|
244
|
+
if (allow.length) {
|
|
245
|
+
allowedSchemes = new Set(this.#allowedSchemes);
|
|
246
|
+
for (const item of allow) {
|
|
206
247
|
if (isString(item)) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
} catch (e) {
|
|
215
|
-
const msg = `Failed to add scheme '${item}' in 'allow' list.`;
|
|
216
|
-
logDebug(debug, msg, e);
|
|
217
|
-
}
|
|
218
|
-
if (super.has(item)) {
|
|
219
|
-
schemeMap.set(item, true);
|
|
220
|
-
tempScheme.add(item);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
248
|
+
this.#registerScheme(
|
|
249
|
+
item.trim(),
|
|
250
|
+
'allow',
|
|
251
|
+
allowedSchemes,
|
|
252
|
+
schemeMap,
|
|
253
|
+
ctx
|
|
254
|
+
);
|
|
224
255
|
}
|
|
225
256
|
}
|
|
226
257
|
}
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
for (let item of items) {
|
|
258
|
+
if (deny.length) {
|
|
259
|
+
for (let item of deny) {
|
|
230
260
|
if (isString(item)) {
|
|
231
261
|
item = item.trim();
|
|
232
262
|
if (item) {
|
|
@@ -237,17 +267,23 @@ class URLSanitizer extends URISchemes {
|
|
|
237
267
|
}
|
|
238
268
|
}
|
|
239
269
|
let sanitizedUrl;
|
|
240
|
-
let isVerified = super.verify(url);
|
|
270
|
+
let isVerified = super.verify(url, allowedSchemes);
|
|
241
271
|
let isRelative = false;
|
|
242
|
-
|
|
272
|
+
let relativeParsedPath = '';
|
|
273
|
+
if (!isVerified && allowRelative && !REG_VERIFY_RELATIVE.test(url)) {
|
|
243
274
|
try {
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
275
|
+
const dummyUrl = new URL(url, 'http://dummy.local');
|
|
276
|
+
if (
|
|
277
|
+
dummyUrl.protocol === 'http:' &&
|
|
278
|
+
dummyUrl.hostname === 'dummy.local'
|
|
279
|
+
) {
|
|
246
280
|
isVerified = true;
|
|
247
281
|
isRelative = true;
|
|
282
|
+
relativeParsedPath =
|
|
283
|
+
`${dummyUrl.pathname}${dummyUrl.search}${dummyUrl.hash}`;
|
|
248
284
|
}
|
|
249
285
|
} catch (e) {
|
|
250
|
-
logDebug(debug, 'Failed to parse relative URL.', e);
|
|
286
|
+
logDebug(ctx.debug, 'Failed to parse relative URL.', e);
|
|
251
287
|
}
|
|
252
288
|
}
|
|
253
289
|
if (isVerified) {
|
|
@@ -276,7 +312,7 @@ class URLSanitizer extends URISchemes {
|
|
|
276
312
|
}
|
|
277
313
|
if (bool) {
|
|
278
314
|
const isDataUrl = isRelative ? false : schemeParts.includes('data');
|
|
279
|
-
let urlToSanitize = isRelative ?
|
|
315
|
+
let urlToSanitize = isRelative ? relativeParsedPath : href;
|
|
280
316
|
if (isDataUrl) {
|
|
281
317
|
const [mediaType, ...dataParts] = pathname.split(',');
|
|
282
318
|
const data = `${dataParts.join(',')}${search}${hash}`;
|
|
@@ -284,27 +320,27 @@ class URLSanitizer extends URISchemes {
|
|
|
284
320
|
const isBase64 = mediaTypes[mediaTypes.length - 1] === 'base64';
|
|
285
321
|
let parsedData = data;
|
|
286
322
|
if (isBase64) {
|
|
287
|
-
|
|
323
|
+
try {
|
|
324
|
+
parsedData = parseBase64(data);
|
|
325
|
+
} catch (e) {
|
|
326
|
+
logDebug(ctx.debug, 'Failed to parse base64 data.', e);
|
|
327
|
+
urlToSanitize = '';
|
|
328
|
+
}
|
|
288
329
|
}
|
|
289
330
|
try {
|
|
290
331
|
const decodedData = parseURLEncodedNumCharRef(parsedData).trim();
|
|
291
|
-
const { protocol: dataScheme } =
|
|
332
|
+
const { protocol: dataScheme } =
|
|
333
|
+
new URL(decodedData, 'http://dummy.local');
|
|
292
334
|
const dataSchemeParts = dataScheme.replace(/:$/, '').split('+');
|
|
293
335
|
if (dataSchemeParts.some(s => REG_SCRIPT_BLOB.test(s))) {
|
|
294
336
|
urlToSanitize = '';
|
|
295
337
|
}
|
|
296
338
|
} catch (e) {
|
|
297
339
|
const msg = 'Failed to parse inner data URL protocol.';
|
|
298
|
-
logDebug(debug, msg, e);
|
|
299
|
-
}
|
|
300
|
-
const containsDataUrl = REG_DATA_URL.test(parsedData);
|
|
301
|
-
if (parsedData !== data || containsDataUrl) {
|
|
302
|
-
if (containsDataUrl) {
|
|
303
|
-
parsedData = this.replace(parsedData);
|
|
304
|
-
}
|
|
340
|
+
logDebug(ctx.debug, msg, e);
|
|
305
341
|
}
|
|
306
342
|
if (!mediaType || REG_MIME_DOM.test(mediaType)) {
|
|
307
|
-
parsedData = this
|
|
343
|
+
parsedData = this.#purify(parsedData, ctx);
|
|
308
344
|
}
|
|
309
345
|
if (urlToSanitize && parsedData) {
|
|
310
346
|
if (isBase64 && parsedData !== data) {
|
|
@@ -322,44 +358,89 @@ class URLSanitizer extends URISchemes {
|
|
|
322
358
|
urlToSanitize.substring(0, index).replace(/[?&]$/, '');
|
|
323
359
|
}
|
|
324
360
|
if (urlToSanitize) {
|
|
325
|
-
sanitizedUrl =
|
|
326
|
-
urlToSanitize.replace(/%26/g, escapeURLEncodedHTMLChars);
|
|
361
|
+
sanitizedUrl = urlToSanitize;
|
|
327
362
|
}
|
|
328
363
|
}
|
|
329
|
-
if (tempScheme.size) {
|
|
330
|
-
tempScheme.forEach(item => {
|
|
331
|
-
super.remove(item);
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
364
|
}
|
|
335
365
|
return sanitizedUrl || null;
|
|
336
366
|
}
|
|
337
367
|
|
|
338
368
|
/**
|
|
339
|
-
*
|
|
340
|
-
* @param {string} url - URL
|
|
341
|
-
* @param {object} [opt] - options
|
|
342
|
-
* @param {
|
|
343
|
-
* @param {
|
|
344
|
-
* @param {
|
|
345
|
-
* @
|
|
369
|
+
* Sanitizes the given URL.
|
|
370
|
+
* @param {string} url - The URL string to sanitize.
|
|
371
|
+
* @param {object} [opt] - Sanitization options.
|
|
372
|
+
* @param {string[]} [opt.allow] - An array of schemes to allow.
|
|
373
|
+
* @param {string[]} [opt.deny] - An array of schemes to deny.
|
|
374
|
+
* @param {string[]} [opt.only] - An array of specific schemes to allow.
|
|
375
|
+
* @param {boolean} [opt.allowRelative] - Flag to safely allow relative URLs.
|
|
376
|
+
* @param {boolean} [opt.debug] - Flag to enable debug mode.
|
|
377
|
+
* @param {number} [opt.maxBlobSize] - The maximum allowed blob size in bytes.
|
|
378
|
+
* @returns {string|null} The sanitized URL, or null.
|
|
346
379
|
*/
|
|
347
|
-
|
|
380
|
+
sanitize(url, opt) {
|
|
381
|
+
if (!url || !isString(url)) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const hasRestrictiveRules = opt && (
|
|
385
|
+
(Array.isArray(opt.deny) && opt.deny.length > 0) ||
|
|
386
|
+
(Array.isArray(opt.only) && opt.only.length > 0) ||
|
|
387
|
+
opt.allowRelative
|
|
388
|
+
);
|
|
389
|
+
if (
|
|
390
|
+
!hasRestrictiveRules &&
|
|
391
|
+
(url.startsWith('https://') || url.startsWith('http://')) &&
|
|
392
|
+
!REG_TAG_QUOT.test(url) &&
|
|
393
|
+
!url.includes('data:')
|
|
394
|
+
) {
|
|
395
|
+
try {
|
|
396
|
+
const { href } = new URL(url);
|
|
397
|
+
const res = href;
|
|
398
|
+
return res.replace(/%26/g, escapeURLEncodedHTMLChars);
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const rules = {
|
|
404
|
+
allow: Array.isArray(opt?.allow) ? opt.allow : [],
|
|
405
|
+
deny: Array.isArray(opt?.deny) ? opt.deny : [],
|
|
406
|
+
only: Array.isArray(opt?.only) ? opt.only : [],
|
|
407
|
+
allowRelative: !!opt?.allowRelative
|
|
408
|
+
};
|
|
409
|
+
const ctx = {
|
|
410
|
+
debug: !!opt?.debug,
|
|
411
|
+
domPurify,
|
|
412
|
+
nest: 0,
|
|
413
|
+
recurse: null,
|
|
414
|
+
schemeMap: new Map([
|
|
415
|
+
['blob', false],
|
|
416
|
+
['data', false],
|
|
417
|
+
['file', false],
|
|
418
|
+
['javascript', false],
|
|
419
|
+
['vbscript', false]
|
|
420
|
+
])
|
|
421
|
+
};
|
|
422
|
+
return this.#process(url, rules, ctx);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Inspects, parses, and sanitizes the given URL.
|
|
427
|
+
* NOTE: blob URLs are simply parsed, but neither decoded nor sanitized.
|
|
428
|
+
* @param {string} url - The URL string to parse.
|
|
429
|
+
* @param {object} [opt] - Sanitization options.
|
|
430
|
+
* @returns {InspectedURLResult} The object containing parsed result.
|
|
431
|
+
*/
|
|
432
|
+
inspect(url, opt) {
|
|
348
433
|
if (!isString(url)) {
|
|
349
434
|
throw new TypeError(`Expected String but got ${getType(url)}.`);
|
|
350
435
|
}
|
|
351
|
-
const parsedUrl = new Map([
|
|
352
|
-
['input', url]
|
|
353
|
-
]);
|
|
436
|
+
const parsedUrl = new Map([['input', url]]);
|
|
354
437
|
let sanitizedUrl;
|
|
355
438
|
if (this.verify(url)) {
|
|
356
439
|
const { protocol } = new URL(url);
|
|
357
440
|
if (protocol === 'blob:') {
|
|
358
441
|
sanitizedUrl = url;
|
|
359
442
|
} else {
|
|
360
|
-
sanitizedUrl = this.sanitize(url, opt ?? {
|
|
361
|
-
allow: ['data', 'file']
|
|
362
|
-
});
|
|
443
|
+
sanitizedUrl = this.sanitize(url, opt ?? { allow: ['data', 'file'] });
|
|
363
444
|
}
|
|
364
445
|
}
|
|
365
446
|
if (sanitizedUrl) {
|
|
@@ -371,7 +452,7 @@ class URLSanitizer extends URISchemes {
|
|
|
371
452
|
if (isDataUrl) {
|
|
372
453
|
const dataUrl = new Map();
|
|
373
454
|
const [mediaType, ...dataParts] = pathname.split(',');
|
|
374
|
-
const data = `${dataParts.join(',')}`;
|
|
455
|
+
const data = `${dataParts.join(',')}${urlObj.search}${urlObj.hash}`;
|
|
375
456
|
const mediaTypes = mediaType.split(';');
|
|
376
457
|
const isBase64 = mediaTypes[mediaTypes.length - 1] === 'base64';
|
|
377
458
|
if (isBase64) {
|
|
@@ -384,7 +465,7 @@ class URLSanitizer extends URISchemes {
|
|
|
384
465
|
} else {
|
|
385
466
|
parsedUrl.set('data', null);
|
|
386
467
|
}
|
|
387
|
-
for (const key
|
|
468
|
+
for (const key of URL_PROPS) {
|
|
388
469
|
const value = urlObj[key];
|
|
389
470
|
if (isString(value)) {
|
|
390
471
|
parsedUrl.set(key, value);
|
|
@@ -397,31 +478,86 @@ class URLSanitizer extends URISchemes {
|
|
|
397
478
|
}
|
|
398
479
|
|
|
399
480
|
/**
|
|
400
|
-
*
|
|
481
|
+
* Gets the list of registered URI schemes.
|
|
482
|
+
* @returns {string[]} An array of registered schemes.
|
|
483
|
+
*/
|
|
484
|
+
get() {
|
|
485
|
+
return [...this.#allowedSchemes];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Checks if the specified scheme is currently registered.
|
|
490
|
+
* @param {string} scheme - The target scheme.
|
|
491
|
+
* @returns {boolean} True if the scheme is registered.
|
|
492
|
+
*/
|
|
493
|
+
has(scheme) {
|
|
494
|
+
return this.#allowedSchemes.has(scheme);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Adds a new scheme to the allowed list.
|
|
499
|
+
* @param {string} scheme - The scheme to add.
|
|
500
|
+
* @returns {string[]} The updated array of registered schemes.
|
|
501
|
+
*/
|
|
502
|
+
add(scheme) {
|
|
503
|
+
if (!isString(scheme)) {
|
|
504
|
+
throw new TypeError(`Expected String but got ${getType(scheme)}.`);
|
|
505
|
+
}
|
|
506
|
+
const schemeParts = scheme.split('+');
|
|
507
|
+
const isScript = schemeParts.some(s => REG_SCRIPT.test(s));
|
|
508
|
+
if (isScript || !REG_SCHEME.test(scheme)) {
|
|
509
|
+
throw new Error(`Invalid scheme: ${scheme}`);
|
|
510
|
+
}
|
|
511
|
+
this.#allowedSchemes.add(scheme);
|
|
512
|
+
return [...this.#allowedSchemes];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Removes a scheme from the allowed list.
|
|
517
|
+
* @param {string} scheme - The scheme to remove.
|
|
518
|
+
* @returns {boolean} True if the scheme was successfully removed.
|
|
519
|
+
*/
|
|
520
|
+
remove(scheme) {
|
|
521
|
+
return this.#allowedSchemes.delete(scheme);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Resets the registered schemes back to the default initial list.
|
|
401
526
|
* @returns {void}
|
|
402
527
|
*/
|
|
403
528
|
reset() {
|
|
404
|
-
super.
|
|
405
|
-
|
|
406
|
-
|
|
529
|
+
this.#allowedSchemes = new Set(super.get());
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Verifies if the given URI is valid and its scheme is allowed.
|
|
534
|
+
* @param {string} uri - The URI string to verify.
|
|
535
|
+
* @param {Set<string>} [schemes] - The set of allowed schemes.
|
|
536
|
+
* @returns {boolean} True if the URI is syntactically valid and permitted.
|
|
537
|
+
*/
|
|
538
|
+
verify(uri, schemes = this.#allowedSchemes) {
|
|
539
|
+
return super.verify(uri, schemes);
|
|
407
540
|
}
|
|
408
541
|
}
|
|
409
542
|
export { URLSanitizer };
|
|
410
543
|
|
|
411
|
-
/* aliases and async wrappers */
|
|
412
544
|
/* instance */
|
|
413
545
|
const urlSanitizer = new URLSanitizer();
|
|
414
546
|
|
|
415
547
|
/**
|
|
416
|
-
*
|
|
417
|
-
* NOTE: `blob`, `data
|
|
418
|
-
*
|
|
548
|
+
* Asynchronously sanitizes the given URL.
|
|
549
|
+
* NOTE: `blob`, `data`, and `file` schemes must be explicitly allowed.
|
|
550
|
+
* Given a `blob` URL, it securely converts and returns a sanitized `data` URL.
|
|
419
551
|
* @param {string} url - URL
|
|
420
552
|
* @param {object} [opt] - options
|
|
421
|
-
* @param {
|
|
422
|
-
* @param {
|
|
423
|
-
* @param {
|
|
424
|
-
* @
|
|
553
|
+
* @param {string[]} [opt.allow] - The array of schemes to allow.
|
|
554
|
+
* @param {string[]} [opt.deny] - The array of schemes to deny.
|
|
555
|
+
* @param {string[]} [opt.only] - The array of specific schemes to allow.
|
|
556
|
+
* @param {boolean} [opt.allowRelative] - Allow relative URLs.
|
|
557
|
+
* @param {boolean} [opt.debug] - Enable debug mode.
|
|
558
|
+
* @param {boolean} [opt.revokeObjectURL] - Revokes the blob URL after sanitization.
|
|
559
|
+
* @param {number} [opt.maxBlobSize] - The maximum allowed blob size in bytes.
|
|
560
|
+
* @returns {Promise<string|null>} A promise resolving to the sanitized URL, or null.
|
|
425
561
|
*/
|
|
426
562
|
export const sanitizeURL = async (url, opt = {
|
|
427
563
|
allow: [],
|
|
@@ -429,6 +565,7 @@ export const sanitizeURL = async (url, opt = {
|
|
|
429
565
|
only: [],
|
|
430
566
|
allowRelative: false,
|
|
431
567
|
debug: false,
|
|
568
|
+
revokeObjectURL: false,
|
|
432
569
|
maxBlobSize: MAX_BLOB_SIZE
|
|
433
570
|
}) => {
|
|
434
571
|
const isDebug = !!opt?.debug;
|
|
@@ -459,23 +596,25 @@ export const sanitizeURL = async (url, opt = {
|
|
|
459
596
|
logDebug(isDebug, msg, e);
|
|
460
597
|
}
|
|
461
598
|
if (data) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
deny.
|
|
599
|
+
const options = { ...opt };
|
|
600
|
+
if (Array.isArray(opt.only)) {
|
|
601
|
+
options.only = opt.only.includes('data')
|
|
602
|
+
? opt.only
|
|
603
|
+
: [...opt.only, 'data'];
|
|
604
|
+
} else if (Array.isArray(opt.allow)) {
|
|
605
|
+
options.allow = opt.allow.includes('data')
|
|
606
|
+
? opt.allow
|
|
607
|
+
: [...opt.allow, 'data'];
|
|
608
|
+
if (Array.isArray(opt.deny)) {
|
|
609
|
+
options.deny = opt.deny.filter(scheme => scheme !== 'data');
|
|
473
610
|
}
|
|
474
611
|
}
|
|
475
|
-
res = urlSanitizer.sanitize(data,
|
|
612
|
+
res = urlSanitizer.sanitize(data, options);
|
|
476
613
|
}
|
|
477
614
|
}
|
|
478
|
-
|
|
615
|
+
if (opt?.revokeObjectURL) {
|
|
616
|
+
URL.revokeObjectURL(url);
|
|
617
|
+
}
|
|
479
618
|
} else if (scheme || opt.allowRelative) {
|
|
480
619
|
res = urlSanitizer.sanitize(url, opt);
|
|
481
620
|
}
|
|
@@ -484,17 +623,28 @@ export const sanitizeURL = async (url, opt = {
|
|
|
484
623
|
};
|
|
485
624
|
|
|
486
625
|
/**
|
|
487
|
-
*
|
|
488
|
-
* NOTE: `data` and `file` schemes must be explicitly allowed
|
|
489
|
-
* `blob` scheme
|
|
626
|
+
* Synchronously sanitizes the given URL.
|
|
627
|
+
* NOTE: `data` and `file` schemes must be explicitly allowed.
|
|
628
|
+
* The `blob` scheme is not supported and will return `null`.
|
|
490
629
|
* @param {string} url - URL
|
|
491
630
|
* @param {object} [opt] - options
|
|
492
|
-
* @param {
|
|
493
|
-
* @param {
|
|
494
|
-
* @param {
|
|
495
|
-
* @
|
|
631
|
+
* @param {string[]} [opt.allow] - The array of schemes to allow.
|
|
632
|
+
* @param {string[]} [opt.deny] - The array of schemes to deny.
|
|
633
|
+
* @param {string[]} [opt.only] - The array of specific schemes to allow.
|
|
634
|
+
* @param {boolean} [opt.allowRelative] - Allow relative URLs.
|
|
635
|
+
* @param {boolean} [opt.debug] - Enable debug mode.
|
|
636
|
+
* @param {boolean} [opt.revokeObjectURL] - Revokes the blob URL.
|
|
637
|
+
* @returns {string|null} The sanitized URL, or null if denied.
|
|
496
638
|
*/
|
|
497
|
-
export const sanitizeURLSync = (url, opt
|
|
639
|
+
export const sanitizeURLSync = (url, opt = {
|
|
640
|
+
allow: [],
|
|
641
|
+
deny: [],
|
|
642
|
+
only: [],
|
|
643
|
+
allowRelative: false,
|
|
644
|
+
debug: false,
|
|
645
|
+
revokeObjectURL: false
|
|
646
|
+
}) => {
|
|
647
|
+
const isDebug = !!opt?.debug;
|
|
498
648
|
let res;
|
|
499
649
|
if (url && isString(url)) {
|
|
500
650
|
let scheme;
|
|
@@ -502,12 +652,15 @@ export const sanitizeURLSync = (url, opt) => {
|
|
|
502
652
|
const { protocol } = new URL(url);
|
|
503
653
|
scheme = protocol.replace(/:$/, '');
|
|
504
654
|
} catch (e) {
|
|
505
|
-
const msg = `
|
|
506
|
-
logDebug(
|
|
655
|
+
const msg = `Invalid URL input format: ${url}`;
|
|
656
|
+
logDebug(isDebug, msg, e);
|
|
507
657
|
}
|
|
508
658
|
if (scheme === 'blob') {
|
|
509
|
-
|
|
510
|
-
|
|
659
|
+
if (opt?.revokeObjectURL) {
|
|
660
|
+
URL.revokeObjectURL(url);
|
|
661
|
+
}
|
|
662
|
+
return null;
|
|
663
|
+
} else if (scheme || opt.allowRelative) {
|
|
511
664
|
res = urlSanitizer.sanitize(url, opt);
|
|
512
665
|
}
|
|
513
666
|
}
|
|
@@ -515,26 +668,40 @@ export const sanitizeURLSync = (url, opt) => {
|
|
|
515
668
|
};
|
|
516
669
|
|
|
517
670
|
/**
|
|
518
|
-
*
|
|
519
|
-
* @param {string} url - URL
|
|
520
|
-
* @returns {Promise
|
|
671
|
+
* Inspects, parses, and sanitizes the given URL asynchronously.
|
|
672
|
+
* @param {string} url - The URL string to inspect.
|
|
673
|
+
* @returns {Promise<InspectedURLResult>} A promise resolving to the inspected URL object.
|
|
521
674
|
*/
|
|
522
|
-
export const
|
|
523
|
-
const res = urlSanitizer.
|
|
675
|
+
export const inspectURL = async url => {
|
|
676
|
+
const res = urlSanitizer.inspect(url);
|
|
524
677
|
return res;
|
|
525
678
|
};
|
|
526
679
|
|
|
527
680
|
/**
|
|
528
|
-
*
|
|
529
|
-
* @param {string} url - URL
|
|
530
|
-
* @returns {
|
|
681
|
+
* @deprecated Use inspectURL instead.
|
|
682
|
+
* @param {string} url - The URL string to inspect.
|
|
683
|
+
* @returns {Promise<InspectedURLResult>} A promise resolving to the inspected URL object.
|
|
684
|
+
*/
|
|
685
|
+
export const parseURL = url => inspectURL(url);
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Inspects, parses, and sanitizes the given URL synchronously.
|
|
689
|
+
* @param {string} url - The URL string to inspect.
|
|
690
|
+
* @returns {InspectedURLResult} The inspected URL object.
|
|
691
|
+
*/
|
|
692
|
+
export const inspectURLSync = url => urlSanitizer.inspect(url);
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* @deprecated Use inspectURLSync instead.
|
|
696
|
+
* @param {string} url - The URL string to inspect.
|
|
697
|
+
* @returns {InspectedURLResult} The inspected URL object.
|
|
531
698
|
*/
|
|
532
|
-
export const parseURLSync = url =>
|
|
699
|
+
export const parseURLSync = url => inspectURLSync(url);
|
|
533
700
|
|
|
534
701
|
/**
|
|
535
|
-
* is URI
|
|
536
|
-
* @param {string} uri - URI
|
|
537
|
-
* @returns {Promise
|
|
702
|
+
* Asynchronously checks if the given string is a valid URI and is registered.
|
|
703
|
+
* @param {string} uri - The URI string to verify.
|
|
704
|
+
* @returns {Promise<boolean>} True if valid and registered, false otherwise.
|
|
538
705
|
*/
|
|
539
706
|
export const isURI = async uri => {
|
|
540
707
|
const res = urlSanitizer.verify(uri);
|
|
@@ -542,9 +709,9 @@ export const isURI = async uri => {
|
|
|
542
709
|
};
|
|
543
710
|
|
|
544
711
|
/**
|
|
545
|
-
* is URI
|
|
546
|
-
* @param {string} uri - URI
|
|
547
|
-
* @returns {boolean}
|
|
712
|
+
* Synchronously checks if the given string is a valid URI and is registered.
|
|
713
|
+
* @param {string} uri - The URI string to verify.
|
|
714
|
+
* @returns {boolean} True if valid and registered, false otherwise.
|
|
548
715
|
*/
|
|
549
716
|
export const isURISync = uri => urlSanitizer.verify(uri);
|
|
550
717
|
|