@reldens/server-utils 0.17.0 → 0.19.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/lib/app-server-factory.js +96 -56
- package/lib/encryptor.js +125 -10
- package/lib/file-handler.js +106 -42
- package/lib/uploader-factory.js +86 -50
- package/package.json +3 -3
|
@@ -56,6 +56,18 @@ class AppServerFactory
|
|
|
56
56
|
this.domains = [];
|
|
57
57
|
this.useVirtualHosts = false;
|
|
58
58
|
this.defaultDomain = '';
|
|
59
|
+
this.maxRequestSize = '10mb';
|
|
60
|
+
this.sanitizeOptions = {allowedTags: [], allowedAttributes: {}};
|
|
61
|
+
this.staticOptions = {
|
|
62
|
+
maxAge: '1d',
|
|
63
|
+
etag: true,
|
|
64
|
+
lastModified: true,
|
|
65
|
+
index: false,
|
|
66
|
+
setHeaders: function(res){
|
|
67
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
68
|
+
res.set('X-Frame-Options', 'DENY');
|
|
69
|
+
}
|
|
70
|
+
};
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
createAppServer(appServerConfig)
|
|
@@ -98,13 +110,7 @@ class AppServerFactory
|
|
|
98
110
|
return next();
|
|
99
111
|
}
|
|
100
112
|
if('object' === typeof req.body){
|
|
101
|
-
|
|
102
|
-
for(let i = 0; i < bodyKeys.length; i++){
|
|
103
|
-
let key = bodyKeys[i];
|
|
104
|
-
if('string' === typeof req.body[key]){
|
|
105
|
-
req.body[key] = sanitizeHtml(req.body[key]);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
113
|
+
this.sanitizeRequestBody(req.body);
|
|
108
114
|
}
|
|
109
115
|
next();
|
|
110
116
|
});
|
|
@@ -135,6 +141,21 @@ class AppServerFactory
|
|
|
135
141
|
return {app: this.app, appServer: this.appServer};
|
|
136
142
|
}
|
|
137
143
|
|
|
144
|
+
sanitizeRequestBody(body)
|
|
145
|
+
{
|
|
146
|
+
let bodyKeys = Object.keys(body);
|
|
147
|
+
for(let i = 0; i < bodyKeys.length; i++){
|
|
148
|
+
let key = bodyKeys[i];
|
|
149
|
+
if('string' === typeof body[key]){
|
|
150
|
+
body[key] = sanitizeHtml(body[key], this.sanitizeOptions);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if('object' === typeof body[key] && null !== body[key]){
|
|
154
|
+
this.sanitizeRequestBody(body[key]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
138
159
|
verifyContentTypeJson(req, res, buf)
|
|
139
160
|
{
|
|
140
161
|
let contentType = req.headers['content-type'] || '';
|
|
@@ -170,7 +191,7 @@ class AppServerFactory
|
|
|
170
191
|
req.domain = this.defaultDomain;
|
|
171
192
|
return next();
|
|
172
193
|
}
|
|
173
|
-
this.error = {message: 'Unknown domain: '+hostname};
|
|
194
|
+
this.error = {message: 'Unknown domain: ' + hostname};
|
|
174
195
|
return res.status(404).send('Domain not found');
|
|
175
196
|
}
|
|
176
197
|
req.domain = domain;
|
|
@@ -180,12 +201,16 @@ class AppServerFactory
|
|
|
180
201
|
|
|
181
202
|
findDomainConfig(hostname)
|
|
182
203
|
{
|
|
204
|
+
if(!hostname || 'string' !== typeof hostname){
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
let cleanHostname = hostname.toLowerCase().trim();
|
|
183
208
|
for(let i = 0; i < this.domains.length; i++){
|
|
184
209
|
let domain = this.domains[i];
|
|
185
|
-
if(domain.hostname ===
|
|
210
|
+
if(domain.hostname === cleanHostname){
|
|
186
211
|
return domain;
|
|
187
212
|
}
|
|
188
|
-
if(domain.aliases && domain.aliases.includes(
|
|
213
|
+
if(domain.aliases && domain.aliases.includes(cleanHostname)){
|
|
189
214
|
return domain;
|
|
190
215
|
}
|
|
191
216
|
}
|
|
@@ -207,19 +232,15 @@ class AppServerFactory
|
|
|
207
232
|
{
|
|
208
233
|
let key = FileHandler.readFile(this.keyPath, 'Key');
|
|
209
234
|
if(!key){
|
|
210
|
-
this.error = {message: 'Could not read SSL key file: '+this.keyPath};
|
|
235
|
+
this.error = {message: 'Could not read SSL key file: ' + this.keyPath};
|
|
211
236
|
return false;
|
|
212
237
|
}
|
|
213
238
|
let cert = FileHandler.readFile(this.certPath, 'Cert');
|
|
214
239
|
if(!cert){
|
|
215
|
-
this.error = {message: 'Could not read SSL certificate file: '+this.certPath};
|
|
240
|
+
this.error = {message: 'Could not read SSL certificate file: ' + this.certPath};
|
|
216
241
|
return false;
|
|
217
242
|
}
|
|
218
|
-
let credentials = {
|
|
219
|
-
key: key.toString(),
|
|
220
|
-
cert: cert.toString(),
|
|
221
|
-
passphrase: this.passphrase
|
|
222
|
-
};
|
|
243
|
+
let credentials = {key, cert, passphrase: this.passphrase};
|
|
223
244
|
if('' !== this.httpsChain){
|
|
224
245
|
let ca = FileHandler.readFile(this.httpsChain, 'Certificate Authority');
|
|
225
246
|
if(ca){
|
|
@@ -251,10 +272,7 @@ class AppServerFactory
|
|
|
251
272
|
this.error = {message: 'Could not read domain SSL certificate: '+domain.certPath};
|
|
252
273
|
return callback(null, null);
|
|
253
274
|
}
|
|
254
|
-
let ctx = require('tls').createSecureContext({
|
|
255
|
-
key: key.toString(),
|
|
256
|
-
cert: cert.toString()
|
|
257
|
-
});
|
|
275
|
+
let ctx = require('tls').createSecureContext({key, cert});
|
|
258
276
|
callback(null, ctx);
|
|
259
277
|
};
|
|
260
278
|
return https.createServer(httpsOptions, this.app);
|
|
@@ -272,11 +290,7 @@ class AppServerFactory
|
|
|
272
290
|
this.error = {message: 'Could not read default SSL certificate file: '+this.certPath};
|
|
273
291
|
return false;
|
|
274
292
|
}
|
|
275
|
-
return {
|
|
276
|
-
key: key.toString(),
|
|
277
|
-
cert: cert.toString(),
|
|
278
|
-
passphrase: this.passphrase
|
|
279
|
-
};
|
|
293
|
+
return {key, cert, passphrase: this.passphrase};
|
|
280
294
|
}
|
|
281
295
|
|
|
282
296
|
listen(port)
|
|
@@ -338,48 +352,26 @@ class AppServerFactory
|
|
|
338
352
|
|
|
339
353
|
async serveStatics(app, statics)
|
|
340
354
|
{
|
|
341
|
-
|
|
342
|
-
this.error = {message: 'Invalid statics path: '+statics};
|
|
343
|
-
return false;
|
|
344
|
-
}
|
|
345
|
-
let staticOptions = {
|
|
346
|
-
maxAge: '1d',
|
|
347
|
-
etag: true,
|
|
348
|
-
lastModified: true,
|
|
349
|
-
index: false,
|
|
350
|
-
setHeaders: function(res){
|
|
351
|
-
res.set('X-Content-Type-Options', 'nosniff');
|
|
352
|
-
}
|
|
353
|
-
};
|
|
354
|
-
app.use(this.applicationFramework.static(statics, staticOptions));
|
|
355
|
+
app.use(this.applicationFramework.static(statics, this.staticOptions));
|
|
355
356
|
return true;
|
|
356
357
|
}
|
|
357
358
|
|
|
358
359
|
async serveStaticsPath(app, staticsPath, statics)
|
|
359
360
|
{
|
|
360
|
-
|
|
361
|
-
this.error = {message: 'Invalid statics path to be served: '+staticsPath+' -> '+statics};
|
|
362
|
-
return false;
|
|
363
|
-
}
|
|
364
|
-
let staticOptions = {
|
|
365
|
-
maxAge: '1d',
|
|
366
|
-
etag: true,
|
|
367
|
-
lastModified: true,
|
|
368
|
-
index: false,
|
|
369
|
-
setHeaders: function(res){
|
|
370
|
-
res.set('X-Content-Type-Options', 'nosniff');
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
app.use(staticsPath, this.applicationFramework.static(statics, staticOptions));
|
|
361
|
+
app.use(staticsPath, this.applicationFramework.static(statics, this.staticOptions));
|
|
374
362
|
return true;
|
|
375
363
|
}
|
|
376
364
|
|
|
377
365
|
addDomain(domainConfig)
|
|
378
366
|
{
|
|
379
|
-
if(!domainConfig.hostname){
|
|
367
|
+
if(!domainConfig || !domainConfig.hostname){
|
|
380
368
|
this.error = {message: 'Domain configuration missing hostname'};
|
|
381
369
|
return false;
|
|
382
370
|
}
|
|
371
|
+
if('string' !== typeof domainConfig.hostname){
|
|
372
|
+
this.error = {message: 'Domain hostname must be a string'};
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
383
375
|
this.domains.push(domainConfig);
|
|
384
376
|
return true;
|
|
385
377
|
}
|
|
@@ -389,7 +381,55 @@ class AppServerFactory
|
|
|
389
381
|
if(!this.appServer){
|
|
390
382
|
return true;
|
|
391
383
|
}
|
|
392
|
-
return
|
|
384
|
+
return this.appServer.close();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
enableCSP(cspOptions)
|
|
388
|
+
{
|
|
389
|
+
let defaults = {
|
|
390
|
+
'default-src': ["'self'"],
|
|
391
|
+
'script-src': ["'self'"],
|
|
392
|
+
'style-src': ["'self'", "'unsafe-inline'"],
|
|
393
|
+
'img-src': ["'self'", "data:", "https:"],
|
|
394
|
+
'font-src': ["'self'"],
|
|
395
|
+
'connect-src': ["'self'"],
|
|
396
|
+
'frame-ancestors': ["'none'"],
|
|
397
|
+
'base-uri': ["'self'"],
|
|
398
|
+
'form-action': ["'self'"]
|
|
399
|
+
};
|
|
400
|
+
let csp = Object.assign({}, defaults, cspOptions);
|
|
401
|
+
let policyString = '';
|
|
402
|
+
let keys = Object.keys(csp);
|
|
403
|
+
for(let i = 0; i < keys.length; i++){
|
|
404
|
+
let directive = keys[i];
|
|
405
|
+
let sources = csp[directive];
|
|
406
|
+
if(0 < i){
|
|
407
|
+
policyString += '; ';
|
|
408
|
+
}
|
|
409
|
+
policyString += directive + ' ' + sources.join(' ');
|
|
410
|
+
}
|
|
411
|
+
this.app.use((req, res, next) => {
|
|
412
|
+
res.setHeader('Content-Security-Policy', policyString);
|
|
413
|
+
next();
|
|
414
|
+
});
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
validateInput(input, type)
|
|
419
|
+
{
|
|
420
|
+
if('string' !== typeof input){
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
let patterns = {
|
|
424
|
+
email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
|
|
425
|
+
username: /^[a-zA-Z0-9_-]{3,30}$/,
|
|
426
|
+
strongPassword: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
|
|
427
|
+
alphanumeric: /^[a-zA-Z0-9]+$/,
|
|
428
|
+
numeric: /^\d+$/,
|
|
429
|
+
hexColor: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
|
430
|
+
ipv4: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
|
|
431
|
+
};
|
|
432
|
+
return patterns[type] ? patterns[type].test(input) : false;
|
|
393
433
|
}
|
|
394
434
|
|
|
395
435
|
}
|
package/lib/encryptor.js
CHANGED
|
@@ -4,37 +4,44 @@
|
|
|
4
4
|
*
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const crypto = require('crypto');
|
|
8
8
|
|
|
9
9
|
class Encryptor
|
|
10
10
|
{
|
|
11
11
|
|
|
12
12
|
constructor()
|
|
13
13
|
{
|
|
14
|
-
|
|
15
|
-
this.iterations = 60000;
|
|
14
|
+
this.iterations = 100000;
|
|
16
15
|
this.keylen = 64;
|
|
17
16
|
this.digest = 'sha512';
|
|
17
|
+
this.saltLength = 32;
|
|
18
|
+
this.algorithm = 'aes-256-gcm';
|
|
19
|
+
this.ivLength = 16;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
encryptPassword(password)
|
|
21
23
|
{
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
if(!password || 'string' !== typeof password){
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
let salt = crypto.randomBytes(this.saltLength);
|
|
28
|
+
let hash = crypto.pbkdf2Sync(password, salt, this.iterations, this.keylen, this.digest);
|
|
29
|
+
return salt.toString('hex') + ':' + hash.toString('hex');
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
validatePassword(password, storedPassword)
|
|
29
33
|
{
|
|
34
|
+
if(!password || !storedPassword || 'string' !== typeof password || 'string' !== typeof storedPassword){
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
30
37
|
let parts = storedPassword.split(':');
|
|
31
38
|
if(2 !== parts.length){
|
|
32
39
|
return false;
|
|
33
40
|
}
|
|
34
|
-
let salt = parts[0];
|
|
41
|
+
let salt = Buffer.from(parts[0], 'hex');
|
|
35
42
|
let storedHash = parts[1];
|
|
36
|
-
let hash = crypto.pbkdf2Sync(password, salt, this.iterations, this.keylen, this.digest)
|
|
37
|
-
return
|
|
43
|
+
let hash = crypto.pbkdf2Sync(password, salt, this.iterations, this.keylen, this.digest);
|
|
44
|
+
return hash.toString('hex') === storedHash;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
generateSecretKey()
|
|
@@ -42,6 +49,114 @@ class Encryptor
|
|
|
42
49
|
return crypto.randomBytes(32).toString('hex');
|
|
43
50
|
}
|
|
44
51
|
|
|
52
|
+
encryptData(data, key)
|
|
53
|
+
{
|
|
54
|
+
if(!data || !key){
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
let iv = crypto.randomBytes(this.ivLength);
|
|
58
|
+
let cipher = crypto.createCipher(this.algorithm, key, iv);
|
|
59
|
+
let encrypted = cipher.update(data, 'utf8', 'hex');
|
|
60
|
+
encrypted += cipher.final('hex');
|
|
61
|
+
let authTag = cipher.getAuthTag();
|
|
62
|
+
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
decryptData(encryptedData, key)
|
|
66
|
+
{
|
|
67
|
+
if(!encryptedData || !key || 'string' !== typeof encryptedData){
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
let parts = encryptedData.split(':');
|
|
71
|
+
if(3 !== parts.length){
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
let iv = Buffer.from(parts[0], 'hex');
|
|
76
|
+
let authTag = Buffer.from(parts[1], 'hex');
|
|
77
|
+
let encrypted = parts[2];
|
|
78
|
+
let decipher = crypto.createDecipher(this.algorithm, key, iv);
|
|
79
|
+
decipher.setAuthTag(authTag);
|
|
80
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
81
|
+
decrypted += decipher.final('utf8');
|
|
82
|
+
return decrypted;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
generateSecureToken(length = 32)
|
|
89
|
+
{
|
|
90
|
+
if(0 >= length || 256 < length){
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return crypto.randomBytes(length).toString('base64url');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
generateTOTP(secret, timeStep = 30)
|
|
97
|
+
{
|
|
98
|
+
if(!secret || 'string' !== typeof secret){
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
let time = Math.floor(Date.now() / 1000 / timeStep);
|
|
102
|
+
let timeBuffer = Buffer.allocUnsafe(8);
|
|
103
|
+
timeBuffer.writeUInt32BE(0, 0);
|
|
104
|
+
timeBuffer.writeUInt32BE(time, 4);
|
|
105
|
+
let hmac = crypto.createHmac('sha1', Buffer.from(secret, 'base32'));
|
|
106
|
+
hmac.update(timeBuffer);
|
|
107
|
+
let digest = hmac.digest();
|
|
108
|
+
let offset = digest[digest.length - 1] & 0x0f;
|
|
109
|
+
let code = (digest.readUInt32BE(offset) & 0x7fffffff) % 1000000;
|
|
110
|
+
return code.toString().padStart(6, '0');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
hashData(data, algorithm = 'sha256')
|
|
114
|
+
{
|
|
115
|
+
if(!data){
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
let validAlgorithms = ['sha256', 'sha512', 'md5'];
|
|
119
|
+
if(-1 === validAlgorithms.indexOf(algorithm)){
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return crypto.createHash(algorithm).update(data).digest('hex');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
generateHMAC(data, secret, algorithm = 'sha256')
|
|
126
|
+
{
|
|
127
|
+
if(!data || !secret){
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
let validAlgorithms = ['sha256', 'sha512'];
|
|
131
|
+
if(-1 === validAlgorithms.indexOf(algorithm)){
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return crypto.createHmac(algorithm, secret).update(data).digest('hex');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
verifyHMAC(data, secret, signature, algorithm = 'sha256')
|
|
138
|
+
{
|
|
139
|
+
if(!data || !secret || !signature){
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
let expectedSignature = this.generateHMAC(data, secret, algorithm);
|
|
143
|
+
if(!expectedSignature){
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
constantTimeCompare(a, b)
|
|
150
|
+
{
|
|
151
|
+
if(!a || !b || 'string' !== typeof a || 'string' !== typeof b){
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if(b.length !== a.length){
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
158
|
+
}
|
|
159
|
+
|
|
45
160
|
}
|
|
46
161
|
|
|
47
162
|
module.exports.Encryptor = new Encryptor();
|
package/lib/file-handler.js
CHANGED
|
@@ -15,6 +15,13 @@ class FileHandler
|
|
|
15
15
|
this.encoding = (process.env.RELDENS_DEFAULT_ENCODING || 'utf8');
|
|
16
16
|
this.sep = path.sep;
|
|
17
17
|
this.error = {message: ''};
|
|
18
|
+
this.maxPathLength = 2048;
|
|
19
|
+
this.dangerousPatterns = [
|
|
20
|
+
'../', '..\\', './', '.\\',
|
|
21
|
+
'/etc/', '/proc/', '/sys/',
|
|
22
|
+
'C:\\Windows\\', 'C:\\System32\\',
|
|
23
|
+
'%2e%2e%2f', '%2e%2e%5c'
|
|
24
|
+
];
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
joinPaths(...args)
|
|
@@ -37,7 +44,16 @@ class FileHandler
|
|
|
37
44
|
return false;
|
|
38
45
|
}
|
|
39
46
|
let pathStr = String(filePath);
|
|
40
|
-
|
|
47
|
+
if(this.maxPathLength < pathStr.length){
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
let normalized = pathStr.replace(/\\/g, '/');
|
|
51
|
+
for(let pattern of this.dangerousPatterns){
|
|
52
|
+
if(normalized.toLowerCase().includes(pattern.toLowerCase())){
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
sanitizePath(filePath)
|
|
@@ -48,7 +64,7 @@ class FileHandler
|
|
|
48
64
|
return String(filePath)
|
|
49
65
|
.replace(/\.\./g, '')
|
|
50
66
|
.replace(/[:*?"<>|]/g, '')
|
|
51
|
-
.substring(0,
|
|
67
|
+
.substring(0, this.maxPathLength);
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
generateSecureFilename(originalName)
|
|
@@ -65,52 +81,49 @@ class FileHandler
|
|
|
65
81
|
|
|
66
82
|
remove(fullPath)
|
|
67
83
|
{
|
|
84
|
+
let deletePath = Array.isArray(fullPath) ? this.joinPaths(...fullPath) : fullPath;
|
|
85
|
+
if(!this.exists(deletePath)){
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
68
88
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
let deletePath = Array.isArray(fullPath) ? this.joinPaths(...fullPath) : fullPath;
|
|
74
|
-
if(fs.existsSync(deletePath)){
|
|
75
|
-
fs.rmSync(deletePath, {recursive: true, force: true});
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
89
|
+
fs.rmSync(deletePath, {recursive: true, force: true});
|
|
90
|
+
return true;
|
|
78
91
|
} catch (error) {
|
|
79
92
|
this.error = {message: 'Failed to remove folder.', error, fullPath};
|
|
93
|
+
return false;
|
|
80
94
|
}
|
|
81
|
-
return false;
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
createFolder(folderPath)
|
|
85
98
|
{
|
|
99
|
+
if(!this.isValidPath(folderPath)){
|
|
100
|
+
this.error = {message: 'Invalid folder path.', folderPath};
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if(this.exists(folderPath)){
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
86
106
|
try {
|
|
87
|
-
if(!this.isValidPath(folderPath)){
|
|
88
|
-
this.error = {message: 'Invalid folder path.', folderPath};
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
if(fs.existsSync(folderPath)){
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
107
|
fs.mkdirSync(folderPath, {recursive: true});
|
|
95
108
|
return true;
|
|
96
109
|
} catch (error) {
|
|
97
110
|
this.error = {message: 'Failed to create folder.', error, folderPath};
|
|
111
|
+
return false;
|
|
98
112
|
}
|
|
99
|
-
return false;
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
copyFolderSync(from, to)
|
|
103
116
|
{
|
|
117
|
+
if(!this.isValidPath(from) || !this.isValidPath(to)){
|
|
118
|
+
this.error = {message: 'Invalid path for folder copy.', from, to};
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
104
121
|
try {
|
|
105
|
-
if(!this.isValidPath(from) || !this.isValidPath(to)){
|
|
106
|
-
this.error = {message: 'Invalid path for folder copy.', from, to};
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
122
|
fs.mkdirSync(to, {recursive: true});
|
|
110
123
|
let folders = fs.readdirSync(from);
|
|
111
124
|
for(let element of folders){
|
|
112
125
|
let elementPath = path.join(from, element);
|
|
113
|
-
if(!
|
|
126
|
+
if(!this.exists(elementPath)){
|
|
114
127
|
continue;
|
|
115
128
|
}
|
|
116
129
|
if(fs.lstatSync(elementPath).isFile()){
|
|
@@ -122,8 +135,8 @@ class FileHandler
|
|
|
122
135
|
return true;
|
|
123
136
|
} catch (error) {
|
|
124
137
|
this.error = {message: 'Failed to copy folder.', error, from, to};
|
|
138
|
+
return false;
|
|
125
139
|
}
|
|
126
|
-
return false;
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
copyFileSyncIfDoesNotExist(from, to)
|
|
@@ -132,9 +145,16 @@ class FileHandler
|
|
|
132
145
|
this.error = {message: 'Invalid path for file copy.', from, to};
|
|
133
146
|
return false;
|
|
134
147
|
}
|
|
135
|
-
if(!
|
|
136
|
-
|
|
148
|
+
if(!this.exists(to)){
|
|
149
|
+
try {
|
|
150
|
+
fs.copyFileSync(from, to);
|
|
151
|
+
return true;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.error = {message: 'Failed to copy file.', error, from, to};
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
137
156
|
}
|
|
157
|
+
return true;
|
|
138
158
|
}
|
|
139
159
|
|
|
140
160
|
copyFile(from, to)
|
|
@@ -154,8 +174,8 @@ class FileHandler
|
|
|
154
174
|
return true;
|
|
155
175
|
} catch (error) {
|
|
156
176
|
this.error = {message: 'Failed to copy file.', error, from, to, origin, dest};
|
|
177
|
+
return false;
|
|
157
178
|
}
|
|
158
|
-
return false;
|
|
159
179
|
}
|
|
160
180
|
|
|
161
181
|
extension(filePath)
|
|
@@ -199,8 +219,8 @@ class FileHandler
|
|
|
199
219
|
return fs.lstatSync(filePath).isFile();
|
|
200
220
|
} catch (error) {
|
|
201
221
|
this.error = {message: 'Can not check file.', error, filePath};
|
|
222
|
+
return false;
|
|
202
223
|
}
|
|
203
|
-
return false;
|
|
204
224
|
}
|
|
205
225
|
|
|
206
226
|
isFolder(dirPath)
|
|
@@ -216,8 +236,8 @@ class FileHandler
|
|
|
216
236
|
return fs.lstatSync(dirPath).isDirectory();
|
|
217
237
|
} catch (error) {
|
|
218
238
|
this.error = {message: 'Can not check folder.', error, dirPath};
|
|
239
|
+
return false;
|
|
219
240
|
}
|
|
220
|
-
return false;
|
|
221
241
|
}
|
|
222
242
|
|
|
223
243
|
getFilesInFolder(dirPath, extensions = [])
|
|
@@ -296,17 +316,12 @@ class FileHandler
|
|
|
296
316
|
this.error = {message: 'File check failed to fetch file contents.', filePath};
|
|
297
317
|
return false;
|
|
298
318
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
this.error = {message: 'Can not read data or empty file.', filePath};
|
|
303
|
-
return false;
|
|
304
|
-
}
|
|
305
|
-
return fileContent;
|
|
306
|
-
} catch(error){
|
|
307
|
-
this.error = {message: 'Error reading file.', filePath, error};
|
|
319
|
+
let fileContent = this.readFile(filePath);
|
|
320
|
+
if(!fileContent){
|
|
321
|
+
this.error = {message: 'Can not read data or empty file.', filePath};
|
|
308
322
|
return false;
|
|
309
323
|
}
|
|
324
|
+
return fileContent;
|
|
310
325
|
}
|
|
311
326
|
|
|
312
327
|
readFile(filePath)
|
|
@@ -334,7 +349,8 @@ class FileHandler
|
|
|
334
349
|
return false;
|
|
335
350
|
}
|
|
336
351
|
try {
|
|
337
|
-
|
|
352
|
+
fs.writeFileSync(fs.openSync(filePath, 'w+'), contents);
|
|
353
|
+
return true;
|
|
338
354
|
} catch(error){
|
|
339
355
|
this.error = {message: 'Error updating file.', filePath, error};
|
|
340
356
|
return false;
|
|
@@ -352,8 +368,8 @@ class FileHandler
|
|
|
352
368
|
return true;
|
|
353
369
|
} catch (error) {
|
|
354
370
|
this.error = {message: 'Error saving the file.', fileName, error};
|
|
371
|
+
return false;
|
|
355
372
|
}
|
|
356
|
-
return false;
|
|
357
373
|
}
|
|
358
374
|
|
|
359
375
|
validateFileType(filePath, allowedType, allowedFileTypes, maxFileSize)
|
|
@@ -416,6 +432,54 @@ class FileHandler
|
|
|
416
432
|
}
|
|
417
433
|
}
|
|
418
434
|
|
|
435
|
+
detectFileType(filePath)
|
|
436
|
+
{
|
|
437
|
+
let buffer = this.getFirstFileBytes(filePath, 16);
|
|
438
|
+
if(!buffer){
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
let signatures = {
|
|
442
|
+
'image/jpeg': [0xFF, 0xD8, 0xFF],
|
|
443
|
+
'image/png': [0x89, 0x50, 0x4E, 0x47],
|
|
444
|
+
'image/gif': [0x47, 0x49, 0x46, 0x38],
|
|
445
|
+
'application/pdf': [0x25, 0x50, 0x44, 0x46],
|
|
446
|
+
'application/zip': [0x50, 0x4B, 0x03, 0x04]
|
|
447
|
+
};
|
|
448
|
+
for(let mimeType of Object.keys(signatures)){
|
|
449
|
+
let signature = signatures[mimeType];
|
|
450
|
+
let matches = true;
|
|
451
|
+
for(let i = 0; i < signature.length; i++){
|
|
452
|
+
if(buffer[i] !== signature[i]){
|
|
453
|
+
matches = false;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if(matches){
|
|
458
|
+
return mimeType;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return 'application/octet-stream';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
quarantineFile(filePath, reason = 'security')
|
|
465
|
+
{
|
|
466
|
+
let quarantineDir = this.joinPaths(process.cwd(), 'quarantine');
|
|
467
|
+
this.createFolder(quarantineDir);
|
|
468
|
+
let timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
469
|
+
let quarantinePath = this.joinPaths(
|
|
470
|
+
quarantineDir,
|
|
471
|
+
timestamp + '-' + reason + '-' + path.basename(filePath)
|
|
472
|
+
);
|
|
473
|
+
return this.copyFile(filePath, quarantinePath);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
createTempFile(prefix = 'temp', extension = '.tmp')
|
|
477
|
+
{
|
|
478
|
+
let tempDir = require('os').tmpdir();
|
|
479
|
+
let fileName = prefix + '-' + this.generateSecureFilename('file' + extension);
|
|
480
|
+
return this.joinPaths(tempDir, fileName);
|
|
481
|
+
}
|
|
482
|
+
|
|
419
483
|
}
|
|
420
484
|
|
|
421
485
|
module.exports.FileHandler = new FileHandler();
|
package/lib/uploader-factory.js
CHANGED
|
@@ -19,12 +19,17 @@ class UploaderFactory
|
|
|
19
19
|
this.allowedExtensions = props.allowedExtensions;
|
|
20
20
|
this.applySecureFileNames = props.applySecureFileNames;
|
|
21
21
|
this.processErrorResponse = props.processErrorResponse || false;
|
|
22
|
+
this.dangerousExtensions = props.dangerousExtensions !== undefined
|
|
23
|
+
? props.dangerousExtensions
|
|
24
|
+
: ['.exe', '.bat', '.cmd', '.com', '.scr', '.pif', '.vbs', '.js'];
|
|
25
|
+
this.maxFilenameLength = props.maxFilenameLength || 255;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
createUploader(fields, buckets, allowedFileTypes)
|
|
25
29
|
{
|
|
26
30
|
if(!this.validateInputs(fields, buckets, allowedFileTypes)){
|
|
27
|
-
|
|
31
|
+
this.error = {message: 'Invalid uploader configuration: ' + this.error.message};
|
|
32
|
+
return false;
|
|
28
33
|
}
|
|
29
34
|
let diskStorageConfiguration = {
|
|
30
35
|
destination: (req, file, cb) => {
|
|
@@ -36,7 +41,10 @@ class UploaderFactory
|
|
|
36
41
|
cb(null, dest);
|
|
37
42
|
}
|
|
38
43
|
};
|
|
39
|
-
diskStorageConfiguration
|
|
44
|
+
diskStorageConfiguration.filename = (req, file, cb) => {
|
|
45
|
+
if(!this.validateFilenameSecurity(file.originalname)){
|
|
46
|
+
return cb(new Error('Invalid filename'));
|
|
47
|
+
}
|
|
40
48
|
if(!this.applySecureFileNames) {
|
|
41
49
|
cb(null, file.originalname);
|
|
42
50
|
return;
|
|
@@ -53,7 +61,7 @@ class UploaderFactory
|
|
|
53
61
|
fileSize: this.maxFileSize
|
|
54
62
|
};
|
|
55
63
|
if(0 < this.fileLimit){
|
|
56
|
-
limits
|
|
64
|
+
limits.files = this.fileLimit;
|
|
57
65
|
}
|
|
58
66
|
let upload = multer({
|
|
59
67
|
storage,
|
|
@@ -95,60 +103,84 @@ class UploaderFactory
|
|
|
95
103
|
if(!req.files){
|
|
96
104
|
return next();
|
|
97
105
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
for(let file of req.files[fieldName]){
|
|
101
|
-
if(!await this.validateFileContents(file, allowedFileTypes[fieldName])){
|
|
102
|
-
if(FileHandler.exists(file.path)){
|
|
103
|
-
FileHandler.remove(file.path);
|
|
104
|
-
}
|
|
105
|
-
let messageContents = 'File contents do not match declared type.';
|
|
106
|
-
if('function' === typeof this.processErrorResponse){
|
|
107
|
-
return this.processErrorResponse(415, messageContents, req, res);
|
|
108
|
-
}
|
|
109
|
-
return res.status(415).send(messageContents);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
next();
|
|
114
|
-
} catch(error){
|
|
115
|
-
let messageProcessing = 'Error processing uploaded files.';
|
|
116
|
-
this.error = {message: messageProcessing, error};
|
|
106
|
+
let validationResult = await this.validateAllUploadedFiles(req, allowedFileTypes);
|
|
107
|
+
if(!validationResult){
|
|
117
108
|
this.cleanupFiles(req.files);
|
|
109
|
+
let messageContents = 'File validation failed.';
|
|
118
110
|
if('function' === typeof this.processErrorResponse){
|
|
119
|
-
return this.processErrorResponse(
|
|
111
|
+
return this.processErrorResponse(415, messageContents, req, res);
|
|
120
112
|
}
|
|
121
|
-
return res.status(
|
|
113
|
+
return res.status(415).send(messageContents);
|
|
122
114
|
}
|
|
115
|
+
next();
|
|
123
116
|
});
|
|
124
117
|
};
|
|
125
118
|
}
|
|
126
119
|
|
|
120
|
+
async validateAllUploadedFiles(req, allowedFileTypes)
|
|
121
|
+
{
|
|
122
|
+
try {
|
|
123
|
+
for(let fieldName in req.files){
|
|
124
|
+
for(let file of req.files[fieldName]){
|
|
125
|
+
if(!await this.validateFileContents(file, allowedFileTypes[fieldName])){
|
|
126
|
+
FileHandler.remove(file.path);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
} catch(error){
|
|
133
|
+
this.error = {message: 'Error processing uploaded files.', error};
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
validateFilenameSecurity(filename)
|
|
139
|
+
{
|
|
140
|
+
if(!filename || 'string' !== typeof filename){
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if(this.maxFilenameLength < filename.length){
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
let ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
|
147
|
+
if(-1 !== this.dangerousExtensions.indexOf(ext)){
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
let dangerous = ['../', '..\\', '/', '\\', '<', '>', ':', '*', '?', '"', '|'];
|
|
151
|
+
for(let char of dangerous){
|
|
152
|
+
if(-1 !== filename.indexOf(char)){
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
127
159
|
validateInputs(fields, buckets, allowedFileTypes)
|
|
128
160
|
{
|
|
129
161
|
if(!Array.isArray(fields)){
|
|
130
162
|
this.error = {message: 'Fields must be an array'};
|
|
131
163
|
return false;
|
|
132
164
|
}
|
|
133
|
-
if(!buckets || typeof buckets
|
|
165
|
+
if(!buckets || 'object' !== typeof buckets){
|
|
134
166
|
this.error = {message: 'Buckets must be an object'};
|
|
135
167
|
return false;
|
|
136
168
|
}
|
|
137
|
-
if(!allowedFileTypes || typeof allowedFileTypes
|
|
169
|
+
if(!allowedFileTypes || 'object' !== typeof allowedFileTypes){
|
|
138
170
|
this.error = {message: 'AllowedFileTypes must be an object'};
|
|
139
171
|
return false;
|
|
140
172
|
}
|
|
141
173
|
for(let field of fields){
|
|
142
|
-
if(!field.name || typeof field.name
|
|
174
|
+
if(!field.name || 'string' !== typeof field.name){
|
|
143
175
|
this.error = {message: 'Field name is invalid'};
|
|
144
176
|
return false;
|
|
145
177
|
}
|
|
146
178
|
if(!buckets[field.name]){
|
|
147
|
-
this.error = {message:
|
|
179
|
+
this.error = {message: 'Missing bucket for field: ' + field.name};
|
|
148
180
|
return false;
|
|
149
181
|
}
|
|
150
182
|
if(!allowedFileTypes[field.name]){
|
|
151
|
-
this.error = {message:
|
|
183
|
+
this.error = {message: 'Missing allowedFileType for field: ' + field.name};
|
|
152
184
|
return false;
|
|
153
185
|
}
|
|
154
186
|
}
|
|
@@ -158,39 +190,49 @@ class UploaderFactory
|
|
|
158
190
|
validateFile(file, allowedFileType, cb)
|
|
159
191
|
{
|
|
160
192
|
if(!allowedFileType){
|
|
161
|
-
return cb(
|
|
193
|
+
return cb();
|
|
194
|
+
}
|
|
195
|
+
if(!this.validateFilenameSecurity(file.originalname)){
|
|
196
|
+
this.error = {message: 'Insecure filename: ' + file.originalname};
|
|
197
|
+
return cb(new Error('Insecure filename: ' + file.originalname));
|
|
162
198
|
}
|
|
163
199
|
let fileExtension = FileHandler.extension(file.originalname).toLowerCase();
|
|
164
200
|
let allowedExtensions = this.allowedExtensions[allowedFileType];
|
|
165
201
|
if(allowedExtensions && !allowedExtensions.includes(fileExtension)){
|
|
166
|
-
this.error = {message:
|
|
167
|
-
return cb(
|
|
202
|
+
this.error = {message: 'Invalid file extension: ' + fileExtension};
|
|
203
|
+
return cb(new Error('Invalid file extension: ' + fileExtension));
|
|
168
204
|
}
|
|
169
205
|
let allowedFileTypeRegex = this.convertToRegex(allowedFileType);
|
|
170
206
|
if(!allowedFileTypeRegex){
|
|
171
207
|
this.error = {message: 'File type could not be converted to regex.', allowedFileType};
|
|
172
|
-
return cb(
|
|
208
|
+
return cb(new Error('File type could not be converted to regex'));
|
|
173
209
|
}
|
|
174
210
|
let mimeTypeValid = allowedFileTypeRegex.test(file.mimetype);
|
|
175
211
|
if(!mimeTypeValid){
|
|
176
|
-
this.error = {message:
|
|
177
|
-
return cb(
|
|
212
|
+
this.error = {message: 'Invalid MIME type: ' + file.mimetype};
|
|
213
|
+
return cb(new Error('Invalid MIME type: ' + file.mimetype));
|
|
178
214
|
}
|
|
179
|
-
return cb(
|
|
215
|
+
return cb();
|
|
180
216
|
}
|
|
181
217
|
|
|
182
218
|
async validateFileContents(file, allowedFileType)
|
|
183
219
|
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
220
|
+
if(!FileHandler.isFile(file.path)){
|
|
221
|
+
this.error = {message: 'File path must be provided.', file};
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
let detectedType = FileHandler.detectFileType(file.path);
|
|
225
|
+
if(detectedType && 'application/octet-stream' !== detectedType){
|
|
226
|
+
let expectedMimeTypes = this.mimeTypes[allowedFileType] || [];
|
|
227
|
+
if(0 < expectedMimeTypes.length && -1 === expectedMimeTypes.indexOf(detectedType)){
|
|
228
|
+
this.error = {
|
|
229
|
+
message: 'File content type mismatch.',
|
|
230
|
+
detected: detectedType, expected: expectedMimeTypes
|
|
231
|
+
};
|
|
187
232
|
return false;
|
|
188
233
|
}
|
|
189
|
-
return FileHandler.validateFileType(file.path, allowedFileType, this.allowedExtensions, this.maxFileSize);
|
|
190
|
-
} catch(error){
|
|
191
|
-
this.error = {message: 'Error validating file contents.', error};
|
|
192
|
-
return false;
|
|
193
234
|
}
|
|
235
|
+
return FileHandler.validateFileType(file.path, allowedFileType, this.allowedExtensions, this.maxFileSize);
|
|
194
236
|
}
|
|
195
237
|
|
|
196
238
|
convertToRegex(key)
|
|
@@ -211,13 +253,7 @@ class UploaderFactory
|
|
|
211
253
|
}
|
|
212
254
|
for(let fieldName in files){
|
|
213
255
|
for(let file of files[fieldName]){
|
|
214
|
-
|
|
215
|
-
if(FileHandler.exists(file.path)){
|
|
216
|
-
FileHandler.remove(file.path);
|
|
217
|
-
}
|
|
218
|
-
} catch(error){
|
|
219
|
-
this.error = {message: 'Error cleaning up file.', error};
|
|
220
|
-
}
|
|
256
|
+
FileHandler.remove(file.path);
|
|
221
257
|
}
|
|
222
258
|
}
|
|
223
259
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reldens/server-utils",
|
|
3
3
|
"scope": "@reldens",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.19.0",
|
|
5
5
|
"description": "Reldens - Server Utils",
|
|
6
6
|
"author": "Damian A. Pastorini",
|
|
7
7
|
"license": "MIT",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"express-rate-limit": "7.5.0",
|
|
42
42
|
"express-session": "1.18.1",
|
|
43
43
|
"helmet": "8.1.0",
|
|
44
|
-
"multer": "
|
|
45
|
-
"sanitize-html": "
|
|
44
|
+
"multer": "2.0.1",
|
|
45
|
+
"sanitize-html": "2.17.0"
|
|
46
46
|
}
|
|
47
47
|
}
|