url-sanitizer 0.1.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/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/url-sanitizer.js +593 -0
- package/dist/url-sanitizer.js.map +7 -0
- package/dist/url-sanitizer.min.js +2 -0
- package/dist/url-sanitizer.min.js.map +7 -0
- package/index.js +7 -0
- package/package.json +56 -0
- package/src/lib/iana/uri-schemes.json +348 -0
- package/src/mjs/common.js +24 -0
- package/src/mjs/uri-util.js +318 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uri-util.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/* shared */
|
|
6
|
+
import { getType, isString } from './common.js';
|
|
7
|
+
import uriSchemes from '../lib/iana/uri-schemes.json' assert { type: 'json' };
|
|
8
|
+
|
|
9
|
+
/* constants */
|
|
10
|
+
const HEX = 16;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* get URL encoded string
|
|
14
|
+
*
|
|
15
|
+
* @param {string} str - string
|
|
16
|
+
* @returns {string} - URL encoded string
|
|
17
|
+
*/
|
|
18
|
+
export const getUrlEncodedString = str => {
|
|
19
|
+
if (!isString(str)) {
|
|
20
|
+
throw new TypeError(`Expected String but got ${getType(str)}.`);
|
|
21
|
+
}
|
|
22
|
+
const chars = [];
|
|
23
|
+
for (const ch of str) {
|
|
24
|
+
chars.push(`%${ch.charCodeAt(0).toString(HEX).toUpperCase()}`);
|
|
25
|
+
}
|
|
26
|
+
return chars.join('');
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* escape URL encoded HTML special chars
|
|
31
|
+
*
|
|
32
|
+
* @param {string} ch - URL encoded (percent encoded) char
|
|
33
|
+
* @returns {string} - escaped URL encoded HTML special char / URL encoded char
|
|
34
|
+
*/
|
|
35
|
+
export const escapeUrlEncodedHtmlChars = ch => {
|
|
36
|
+
if (isString(ch)) {
|
|
37
|
+
if (/^%[\dA-F]{2}$/i.test(ch)) {
|
|
38
|
+
ch = ch.toUpperCase();
|
|
39
|
+
} else {
|
|
40
|
+
throw new Error(`${ch} is not a URL encoded character.`);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
throw new TypeError(`Expected String but got ${getType(ch)}.`);
|
|
44
|
+
}
|
|
45
|
+
const [amp, num, lt, gt, quot, apos] =
|
|
46
|
+
['&', '#', '<', '>', '"', "'"].map(getUrlEncodedString);
|
|
47
|
+
let escapedChar;
|
|
48
|
+
if (ch === amp) {
|
|
49
|
+
escapedChar = `${amp}amp;`;
|
|
50
|
+
} else if (ch === lt) {
|
|
51
|
+
escapedChar = `${amp}lt;`;
|
|
52
|
+
} else if (ch === gt) {
|
|
53
|
+
escapedChar = `${amp}gt;`;
|
|
54
|
+
} else if (ch === quot) {
|
|
55
|
+
escapedChar = `${amp}quot;`;
|
|
56
|
+
} else if (ch === apos) {
|
|
57
|
+
escapedChar = `${amp}${num}39;`;
|
|
58
|
+
} else {
|
|
59
|
+
escapedChar = ch;
|
|
60
|
+
}
|
|
61
|
+
return escapedChar;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* parse base64
|
|
66
|
+
*
|
|
67
|
+
* @see {@link https://github.com/file/file/blob/master/src/encoding.c}
|
|
68
|
+
* @param {string} data - base64 data
|
|
69
|
+
* @returns {string} - parsed data / base64 data
|
|
70
|
+
*/
|
|
71
|
+
export const parseBase64 = data => {
|
|
72
|
+
if (!isString(data)) {
|
|
73
|
+
throw new TypeError(`Expected String but got ${getType(data)}.`);
|
|
74
|
+
}
|
|
75
|
+
const bin = atob(data);
|
|
76
|
+
const uint8arr = Uint8Array.from([...bin].map(c => c.charCodeAt(0)));
|
|
77
|
+
const textChars = new Set([0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1b]);
|
|
78
|
+
for (let i = 0x20; i < 0x7f; i++) {
|
|
79
|
+
textChars.add(i);
|
|
80
|
+
}
|
|
81
|
+
for (let i = 0x80; i <= 0xff; i++) {
|
|
82
|
+
textChars.add(i);
|
|
83
|
+
}
|
|
84
|
+
let parsedData;
|
|
85
|
+
if (uint8arr.every(c => textChars.has(c))) {
|
|
86
|
+
parsedData = bin;
|
|
87
|
+
} else {
|
|
88
|
+
parsedData = data;
|
|
89
|
+
}
|
|
90
|
+
return parsedData;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* URI schemes
|
|
95
|
+
*
|
|
96
|
+
*/
|
|
97
|
+
export class URISchemes {
|
|
98
|
+
/* private fields */
|
|
99
|
+
#schemes;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* construct
|
|
103
|
+
*/
|
|
104
|
+
constructor() {
|
|
105
|
+
this.#schemes = new Set(uriSchemes);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* get schemes
|
|
110
|
+
*
|
|
111
|
+
* @see {@link https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml}
|
|
112
|
+
* - Historical schemes omitted
|
|
113
|
+
* - Added 'moz-extension' scheme by default
|
|
114
|
+
* @returns {Array.<string>} - array of schemes
|
|
115
|
+
*/
|
|
116
|
+
get() {
|
|
117
|
+
return [...this.#schemes];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* has scheme
|
|
122
|
+
*
|
|
123
|
+
* @param {string} scheme - scheme
|
|
124
|
+
* @returns {boolean} - result
|
|
125
|
+
*/
|
|
126
|
+
has(scheme) {
|
|
127
|
+
return this.#schemes.has(scheme);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* add scheme
|
|
132
|
+
* NOTE: `javascript` and/or `vbscript` schemes can not be registered
|
|
133
|
+
*
|
|
134
|
+
* @param {string} scheme - scheme
|
|
135
|
+
* @returns {Array.<string>} - array of schemes
|
|
136
|
+
*/
|
|
137
|
+
add(scheme) {
|
|
138
|
+
if (!isString(scheme)) {
|
|
139
|
+
throw new TypeError(`Expected String but got ${getType(scheme)}.`);
|
|
140
|
+
} else if (/(?:java|vb)script/.test(scheme) ||
|
|
141
|
+
!/^[a-z][a-z0-9+\-.]*$/.test(scheme)) {
|
|
142
|
+
throw new Error(`Invalid scheme: ${scheme}`);
|
|
143
|
+
}
|
|
144
|
+
this.#schemes.add(scheme);
|
|
145
|
+
return [...this.#schemes];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* remove scheme
|
|
150
|
+
*
|
|
151
|
+
* @param {string} scheme - scheme
|
|
152
|
+
* @returns {boolean} - result
|
|
153
|
+
*/
|
|
154
|
+
remove(scheme) {
|
|
155
|
+
return this.#schemes.delete(scheme);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* is URI
|
|
160
|
+
*
|
|
161
|
+
* @param {string} uri - URI input
|
|
162
|
+
* @returns {boolean} - result
|
|
163
|
+
*/
|
|
164
|
+
isURI(uri) {
|
|
165
|
+
let res;
|
|
166
|
+
if (isString(uri)) {
|
|
167
|
+
try {
|
|
168
|
+
const { protocol } = new URL(uri);
|
|
169
|
+
const scheme = protocol.replace(/:$/, '');
|
|
170
|
+
const schemeParts = scheme.split('+');
|
|
171
|
+
res = /^(?:ext|web)\+[a-z]+$/.test(scheme) ||
|
|
172
|
+
schemeParts.every(s => this.#schemes.has(s));
|
|
173
|
+
} catch (e) {
|
|
174
|
+
res = false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return !!res;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* URL sanitizer */
|
|
182
|
+
export class URLSanitizer extends URISchemes {
|
|
183
|
+
/**
|
|
184
|
+
* sanitize URL
|
|
185
|
+
* NOTE: `data` and/or `file` schemes must be explicitly allowed
|
|
186
|
+
* `javascript` and/or `vbscript` schemes can not be allowed
|
|
187
|
+
*
|
|
188
|
+
* @param {string} url - URL input
|
|
189
|
+
* @param {object} opt - options
|
|
190
|
+
* @param {Array.<string>} opt.allow - array of allowed schemes
|
|
191
|
+
* @param {Array.<string>} opt.deny - array of denied schemes
|
|
192
|
+
* @param {boolean} opt.escapeTags - escape tags and quotes in data URL
|
|
193
|
+
* @returns {?string} - sanitized URL
|
|
194
|
+
*/
|
|
195
|
+
sanitize(url, opt = { allow: [], deny: [], escapeTags: true }) {
|
|
196
|
+
let sanitizedUrl;
|
|
197
|
+
if (super.isURI(url)) {
|
|
198
|
+
const { allow, deny, escapeTags } = opt ?? {};
|
|
199
|
+
const { href, pathname, protocol } = new URL(url);
|
|
200
|
+
const scheme = protocol.replace(/:$/, '');
|
|
201
|
+
const schemeParts = scheme.split('+');
|
|
202
|
+
const schemeMap = new Map([['data', false], ['file', false]]);
|
|
203
|
+
if (Array.isArray(allow) && allow.length) {
|
|
204
|
+
const items = Object.values(allow);
|
|
205
|
+
for (let item of items) {
|
|
206
|
+
if (isString(item)) {
|
|
207
|
+
item = item.trim();
|
|
208
|
+
if (/(?:java|vb)script/.test(item)) {
|
|
209
|
+
schemeMap.set(item, false);
|
|
210
|
+
} else if (item) {
|
|
211
|
+
schemeMap.set(item, true);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(deny) && deny.length) {
|
|
217
|
+
const items = Object.values(deny);
|
|
218
|
+
for (let item of items) {
|
|
219
|
+
if (isString(item)) {
|
|
220
|
+
item = item.trim();
|
|
221
|
+
if (item) {
|
|
222
|
+
schemeMap.set(item, false);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
let bool;
|
|
228
|
+
for (const [key, value] of schemeMap.entries()) {
|
|
229
|
+
bool = value || (scheme !== key && schemeParts.every(s => s !== key));
|
|
230
|
+
if (!bool) {
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (bool) {
|
|
235
|
+
let urlToSanitize = href;
|
|
236
|
+
if (schemeParts.includes('data')) {
|
|
237
|
+
const [header, data] = pathname.split(',');
|
|
238
|
+
const mediaType = header.split(';');
|
|
239
|
+
const isBase64 = mediaType.pop() === 'base64';
|
|
240
|
+
if (isBase64) {
|
|
241
|
+
const parsedData = parseBase64(data);
|
|
242
|
+
if (parsedData !== data) {
|
|
243
|
+
urlToSanitize = `${scheme}:${mediaType.join(';')},${parsedData}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const [amp, lt, gt, quot, apos] =
|
|
248
|
+
['&', '<', '>', '"', "'"].map(getUrlEncodedString);
|
|
249
|
+
const regChars = /[<>"']/g;
|
|
250
|
+
const regAmp = new RegExp(amp, 'g');
|
|
251
|
+
const regEncodedChars =
|
|
252
|
+
new RegExp(`(${lt}|${gt}|${quot}|${apos})`, 'g');
|
|
253
|
+
if (schemeParts.includes('data') && !(escapeTags ?? true)) {
|
|
254
|
+
sanitizedUrl = urlToSanitize.replace(regChars, getUrlEncodedString)
|
|
255
|
+
.replace(regAmp, escapeUrlEncodedHtmlChars);
|
|
256
|
+
} else {
|
|
257
|
+
sanitizedUrl = urlToSanitize.replace(regChars, getUrlEncodedString)
|
|
258
|
+
.replace(regAmp, escapeUrlEncodedHtmlChars)
|
|
259
|
+
.replace(regEncodedChars, escapeUrlEncodedHtmlChars);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return sanitizedUrl || null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const urlSanitizer = new URLSanitizer();
|
|
268
|
+
|
|
269
|
+
/* alias */
|
|
270
|
+
/**
|
|
271
|
+
* is URI sync
|
|
272
|
+
*
|
|
273
|
+
* @param {string} uri - URI input
|
|
274
|
+
* @returns {boolean} - result
|
|
275
|
+
*/
|
|
276
|
+
const isUri = uri => urlSanitizer.isURI(uri);
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* is URI
|
|
280
|
+
*
|
|
281
|
+
* @param {string} uri - URI input
|
|
282
|
+
* @returns {Promise.<boolean>} - result
|
|
283
|
+
*/
|
|
284
|
+
export const isURI = async uri => {
|
|
285
|
+
const res = await isUri(uri);
|
|
286
|
+
return res;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* sanitize URL sync
|
|
291
|
+
*
|
|
292
|
+
* @param {string} url - URL input
|
|
293
|
+
* @param {object} opt - options
|
|
294
|
+
* @returns {?string} - sanitized URL
|
|
295
|
+
*/
|
|
296
|
+
const sanitizeUrl = (url, opt) => urlSanitizer.sanitize(url, opt ?? {
|
|
297
|
+
allow: [],
|
|
298
|
+
deny: [],
|
|
299
|
+
escapeTags: true
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* sanitize URL
|
|
304
|
+
*
|
|
305
|
+
* @param {string} url - URL input
|
|
306
|
+
* @param {object} opt - options
|
|
307
|
+
* @returns {Promise<?string>} - sanitized URL
|
|
308
|
+
*/
|
|
309
|
+
export const sanitizeURL = async (url, opt) => {
|
|
310
|
+
const res = await sanitizeUrl(url, opt);
|
|
311
|
+
return res;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export {
|
|
315
|
+
urlSanitizer as default,
|
|
316
|
+
isUri as isURISync,
|
|
317
|
+
sanitizeUrl as sanitizeURLSync
|
|
318
|
+
};
|