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.
@@ -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
+ };