@reldens/server-utils 0.17.0 → 0.18.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.
@@ -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
- let bodyKeys = Object.keys(req.body);
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 === hostname){
210
+ if(domain.hostname === cleanHostname){
186
211
  return domain;
187
212
  }
188
- if(domain.aliases && domain.aliases.includes(hostname)){
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
- if(!FileHandler.isValidPath(statics)){
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
- if(!FileHandler.isValidPath(staticsPath) || !FileHandler.isValidPath(statics)){
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 await this.appServer.close();
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
- let crypto = require('crypto');
7
+ const crypto = require('crypto');
8
8
 
9
9
  class Encryptor
10
10
  {
11
11
 
12
12
  constructor()
13
13
  {
14
- // recommended minimum for PBKDF2 with SHA-512
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
- // generate the password hash:
23
- let salt = crypto.randomBytes(16).toString('hex');
24
- let hash = crypto.pbkdf2Sync(password, salt, this.iterations, this.keylen, this.digest ).toString('hex');
25
- return salt + ':' + hash;
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).toString('hex');
37
- return storedHash === hash;
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();
@@ -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
- return !(pathStr.includes('../') || pathStr.includes('..\\'));
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, 255);
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
- if(!this.isValidPath(fullPath)){
70
- this.error = {message: 'Invalid path for removal.', fullPath};
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(!fs.existsSync(elementPath)){
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(!fs.existsSync(to)){
136
- return fs.copyFileSync(from, to);
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
- try {
300
- let fileContent = this.readFile(filePath);
301
- if(!fileContent){
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
- return fs.writeFileSync(fs.openSync(filePath, 'w+'), contents);
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();
@@ -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
- throw new Error('Invalid uploader configuration: ' + this.error.message);
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['filename'] = (req, file, cb) => {
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['files'] = this.fileLimit;
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
- try {
99
- for(let fieldName in req.files){
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(500, messageProcessing, req, res);
111
+ return this.processErrorResponse(415, messageContents, req, res);
120
112
  }
121
- return res.status(500).send(messageProcessing);
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 !== 'object'){
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 !== 'object'){
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 !== 'string'){
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: `Missing bucket for field: ${field.name}`};
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: `Missing allowedFileType for field: ${field.name}`};
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(null, true);
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: `Invalid file extension: ${fileExtension}`};
167
- return cb(null, false);
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(null, false);
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: `Invalid MIME type: ${file.mimetype}`};
177
- return cb(null, false);
212
+ this.error = {message: 'Invalid MIME type: ' + file.mimetype};
213
+ return cb(new Error('Invalid MIME type: ' + file.mimetype));
178
214
  }
179
- return cb(null, true);
215
+ return cb();
180
216
  }
181
217
 
182
218
  async validateFileContents(file, allowedFileType)
183
219
  {
184
- try {
185
- if(!FileHandler.isFile(file.path)){
186
- this.error = {message: 'File path must be provided.', file};
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
- try {
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.17.0",
4
+ "version": "0.18.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": "^1.4.5-lts.2",
44
+ "multer": "2.0.0",
45
45
  "sanitize-html": "^2.17.0"
46
46
  }
47
47
  }