@reldens/server-utils 0.16.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.
@@ -51,6 +51,23 @@ class AppServerFactory
51
51
  this.tooManyRequestsMessage = 'Too many requests, please try again later.';
52
52
  this.error = {};
53
53
  this.processErrorResponse = false;
54
+ this.port = 3000;
55
+ this.autoListen = false;
56
+ this.domains = [];
57
+ this.useVirtualHosts = false;
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
+ };
54
71
  }
55
72
 
56
73
  createAppServer(appServerConfig)
@@ -61,6 +78,9 @@ class AppServerFactory
61
78
  if(this.useHelmet){
62
79
  this.app.use(this.helmetConfig ? helmet(this.helmetConfig) : helmet());
63
80
  }
81
+ if(this.useVirtualHosts){
82
+ this.setupVirtualHosts();
83
+ }
64
84
  if(this.useCors){
65
85
  let corsOptions = {
66
86
  origin: this.corsOrigin,
@@ -89,12 +109,8 @@ class AppServerFactory
89
109
  if(!req.body){
90
110
  return next();
91
111
  }
92
- if(typeof req.body === 'object'){
93
- for(let key in req.body){
94
- if(typeof req.body[key] === 'string'){
95
- req.body[key] = sanitizeHtml(req.body[key]);
96
- }
97
- }
112
+ if('object' === typeof req.body){
113
+ this.sanitizeRequestBody(req.body);
98
114
  }
99
115
  next();
100
116
  });
@@ -102,7 +118,7 @@ class AppServerFactory
102
118
  if(this.useExpressJson){
103
119
  this.app.use(this.applicationFramework.json({
104
120
  limit: this.jsonLimit,
105
- verify: this.verifyContentTypeJson
121
+ verify: this.verifyContentTypeJson.bind(this)
106
122
  }));
107
123
  }
108
124
  if(this.useUrlencoded){
@@ -115,20 +131,90 @@ class AppServerFactory
115
131
  this.app.enable('trust proxy', this.trustedProxy);
116
132
  }
117
133
  this.appServer = this.createServer();
134
+ if(!this.appServer){
135
+ this.error = {message: 'Failed to create app server'};
136
+ return false;
137
+ }
138
+ if(this.autoListen){
139
+ this.listen();
140
+ }
118
141
  return {app: this.app, appServer: this.appServer};
119
142
  }
120
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
+
121
159
  verifyContentTypeJson(req, res, buf)
122
160
  {
123
161
  let contentType = req.headers['content-type'] || '';
124
162
  if(
125
- req.method === 'POST'
163
+ 'POST' === req.method
126
164
  && 0 < buf.length
127
165
  && !contentType.includes('application/json')
128
166
  && !contentType.includes('multipart/form-data')
129
167
  ){
130
- throw new Error('Invalid content-type');
168
+ this.error = {message: 'Invalid content-type for JSON request'};
169
+ return false;
170
+ }
171
+ }
172
+
173
+ setupVirtualHosts()
174
+ {
175
+ if(0 === this.domains.length){
176
+ return;
177
+ }
178
+ this.app.use((req, res, next) => {
179
+ let hostname = req.get('host');
180
+ if(!hostname){
181
+ if(this.defaultDomain){
182
+ req.domain = this.defaultDomain;
183
+ return next();
184
+ }
185
+ this.error = {message: 'No hostname provided and no default domain configured'};
186
+ return res.status(400).send('Bad Request');
187
+ }
188
+ let domain = this.findDomainConfig(hostname);
189
+ if(!domain){
190
+ if(this.defaultDomain){
191
+ req.domain = this.defaultDomain;
192
+ return next();
193
+ }
194
+ this.error = {message: 'Unknown domain: ' + hostname};
195
+ return res.status(404).send('Domain not found');
196
+ }
197
+ req.domain = domain;
198
+ next();
199
+ });
200
+ }
201
+
202
+ findDomainConfig(hostname)
203
+ {
204
+ if(!hostname || 'string' !== typeof hostname){
205
+ return false;
206
+ }
207
+ let cleanHostname = hostname.toLowerCase().trim();
208
+ for(let i = 0; i < this.domains.length; i++){
209
+ let domain = this.domains[i];
210
+ if(domain.hostname === cleanHostname){
211
+ return domain;
212
+ }
213
+ if(domain.aliases && domain.aliases.includes(cleanHostname)){
214
+ return domain;
215
+ }
131
216
  }
217
+ return false;
132
218
  }
133
219
 
134
220
  createServer()
@@ -136,19 +222,25 @@ class AppServerFactory
136
222
  if(!this.useHttps){
137
223
  return http.createServer(this.app);
138
224
  }
225
+ if(this.useVirtualHosts && 0 < this.domains.length){
226
+ return this.createHttpsServerWithSNI();
227
+ }
228
+ return this.createSingleHttpsServer();
229
+ }
230
+
231
+ createSingleHttpsServer()
232
+ {
139
233
  let key = FileHandler.readFile(this.keyPath, 'Key');
140
234
  if(!key){
235
+ this.error = {message: 'Could not read SSL key file: ' + this.keyPath};
141
236
  return false;
142
237
  }
143
238
  let cert = FileHandler.readFile(this.certPath, 'Cert');
144
239
  if(!cert){
240
+ this.error = {message: 'Could not read SSL certificate file: ' + this.certPath};
145
241
  return false;
146
242
  }
147
- let credentials = {
148
- key: key.toString(),
149
- cert: cert.toString(),
150
- passphrase: this.passphrase
151
- };
243
+ let credentials = {key, cert, passphrase: this.passphrase};
152
244
  if('' !== this.httpsChain){
153
245
  let ca = FileHandler.readFile(this.httpsChain, 'Certificate Authority');
154
246
  if(ca){
@@ -158,6 +250,60 @@ class AppServerFactory
158
250
  return https.createServer(credentials, this.app);
159
251
  }
160
252
 
253
+ createHttpsServerWithSNI()
254
+ {
255
+ let defaultCredentials = this.loadDefaultCredentials();
256
+ if(!defaultCredentials){
257
+ return false;
258
+ }
259
+ let httpsOptions = Object.assign({}, defaultCredentials);
260
+ httpsOptions.SNICallback = (hostname, callback) => {
261
+ let domain = this.findDomainConfig(hostname);
262
+ if(!domain || !domain.keyPath || !domain.certPath){
263
+ return callback(null, null);
264
+ }
265
+ let key = FileHandler.readFile(domain.keyPath, 'Domain Key');
266
+ if(!key){
267
+ this.error = {message: 'Could not read domain SSL key: '+domain.keyPath};
268
+ return callback(null, null);
269
+ }
270
+ let cert = FileHandler.readFile(domain.certPath, 'Domain Cert');
271
+ if(!cert){
272
+ this.error = {message: 'Could not read domain SSL certificate: '+domain.certPath};
273
+ return callback(null, null);
274
+ }
275
+ let ctx = require('tls').createSecureContext({key, cert});
276
+ callback(null, ctx);
277
+ };
278
+ return https.createServer(httpsOptions, this.app);
279
+ }
280
+
281
+ loadDefaultCredentials()
282
+ {
283
+ let key = FileHandler.readFile(this.keyPath, 'Default Key');
284
+ if(!key){
285
+ this.error = {message: 'Could not read default SSL key file: '+this.keyPath};
286
+ return false;
287
+ }
288
+ let cert = FileHandler.readFile(this.certPath, 'Default Cert');
289
+ if(!cert){
290
+ this.error = {message: 'Could not read default SSL certificate file: '+this.certPath};
291
+ return false;
292
+ }
293
+ return {key, cert, passphrase: this.passphrase};
294
+ }
295
+
296
+ listen(port)
297
+ {
298
+ let listenPort = port || this.port;
299
+ if(!this.appServer){
300
+ this.error = {message: 'Cannot listen: app server not created'};
301
+ return false;
302
+ }
303
+ this.appServer.listen(listenPort);
304
+ return true;
305
+ }
306
+
161
307
  async enableServeHome(app, homePageLoadCallback)
162
308
  {
163
309
  let limiterParams = {
@@ -189,16 +335,16 @@ class AppServerFactory
189
335
  }
190
336
  return res.status(500).send(errorMessage);
191
337
  }
192
- try {
193
- return res.send(await homePageLoadCallback(req));
194
- } catch(error){
195
- let message = 'Error loading homepage.';
196
- this.error = {message, error};
338
+ let homepageContent = await homePageLoadCallback(req);
339
+ if(!homepageContent){
340
+ let message = 'Error loading homepage content';
341
+ this.error = {message};
197
342
  if('function' === typeof this.processErrorResponse){
198
343
  return this.processErrorResponse(500, message, req, res);
199
344
  }
200
345
  return res.status(500).send(message);
201
346
  }
347
+ return res.send(homepageContent);
202
348
  }
203
349
  next();
204
350
  });
@@ -206,38 +352,84 @@ class AppServerFactory
206
352
 
207
353
  async serveStatics(app, statics)
208
354
  {
209
- if(!FileHandler.isValidPath(statics)){
210
- this.error = {message: 'Invalid statics path.'};
211
- return;
212
- }
213
- let staticOptions = {
214
- maxAge: '1d',
215
- etag: true,
216
- lastModified: true,
217
- index: false,
218
- setHeaders: function(res){
219
- res.set('X-Content-Type-Options', 'nosniff');
220
- }
221
- };
222
- app.use(this.applicationFramework.static(statics, staticOptions));
355
+ app.use(this.applicationFramework.static(statics, this.staticOptions));
356
+ return true;
223
357
  }
224
358
 
225
359
  async serveStaticsPath(app, staticsPath, statics)
226
360
  {
227
- if(!FileHandler.isValidPath(staticsPath) || !FileHandler.isValidPath(statics)){
228
- this.error = {message: 'Invalid statics path to be served.'};
229
- return;
361
+ app.use(staticsPath, this.applicationFramework.static(statics, this.staticOptions));
362
+ return true;
363
+ }
364
+
365
+ addDomain(domainConfig)
366
+ {
367
+ if(!domainConfig || !domainConfig.hostname){
368
+ this.error = {message: 'Domain configuration missing hostname'};
369
+ return false;
230
370
  }
231
- let staticOptions = {
232
- maxAge: '1d',
233
- etag: true,
234
- lastModified: true,
235
- index: false,
236
- setHeaders: function(res){
237
- res.set('X-Content-Type-Options', 'nosniff');
371
+ if('string' !== typeof domainConfig.hostname){
372
+ this.error = {message: 'Domain hostname must be a string'};
373
+ return false;
374
+ }
375
+ this.domains.push(domainConfig);
376
+ return true;
377
+ }
378
+
379
+ async close()
380
+ {
381
+ if(!this.appServer){
382
+ return true;
383
+ }
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 += '; ';
238
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]?)$/
239
431
  };
240
- app.use(staticsPath, this.applicationFramework.static(statics, staticOptions));
432
+ return patterns[type] ? patterns[type].test(input) : false;
241
433
  }
242
434
 
243
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,55 @@ 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;
224
+ }
225
+
226
+ isFolder(dirPath)
227
+ {
228
+ if(!this.isValidPath(dirPath)){
229
+ this.error = {message: 'Invalid folder path.', dirPath};
230
+ return false;
231
+ }
232
+ if(!this.exists(dirPath)){
233
+ return false;
234
+ }
235
+ try {
236
+ return fs.lstatSync(dirPath).isDirectory();
237
+ } catch (error) {
238
+ this.error = {message: 'Can not check folder.', error, dirPath};
239
+ return false;
240
+ }
241
+ }
242
+
243
+ getFilesInFolder(dirPath, extensions = [])
244
+ {
245
+ if(!this.isValidPath(dirPath)){
246
+ this.error = {message: 'Invalid folder path.', dirPath};
247
+ return [];
248
+ }
249
+ let files = this.readFolder(dirPath);
250
+ if(0 === files.length){
251
+ return [];
252
+ }
253
+ let result = [];
254
+ for(let file of files){
255
+ let filePath = path.join(dirPath, file);
256
+ if(!this.isFile(filePath)){
257
+ continue;
258
+ }
259
+ if(0 === extensions.length){
260
+ result.push(file);
261
+ continue;
262
+ }
263
+ for(let ext of extensions){
264
+ if(file.endsWith(ext)){
265
+ result.push(file);
266
+ break;
267
+ }
268
+ }
269
+ }
270
+ return result;
204
271
  }
205
272
 
206
273
  permissionsCheck(systemPath)
@@ -249,17 +316,12 @@ class FileHandler
249
316
  this.error = {message: 'File check failed to fetch file contents.', filePath};
250
317
  return false;
251
318
  }
252
- try {
253
- let fileContent = this.readFile(filePath);
254
- if(!fileContent){
255
- this.error = {message: 'Can not read data or empty file.', filePath};
256
- return false;
257
- }
258
- return fileContent;
259
- } catch(error){
260
- 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};
261
322
  return false;
262
323
  }
324
+ return fileContent;
263
325
  }
264
326
 
265
327
  readFile(filePath)
@@ -287,7 +349,8 @@ class FileHandler
287
349
  return false;
288
350
  }
289
351
  try {
290
- return fs.writeFileSync(fs.openSync(filePath, 'w+'), contents);
352
+ fs.writeFileSync(fs.openSync(filePath, 'w+'), contents);
353
+ return true;
291
354
  } catch(error){
292
355
  this.error = {message: 'Error updating file.', filePath, error};
293
356
  return false;
@@ -305,8 +368,8 @@ class FileHandler
305
368
  return true;
306
369
  } catch (error) {
307
370
  this.error = {message: 'Error saving the file.', fileName, error};
371
+ return false;
308
372
  }
309
- return false;
310
373
  }
311
374
 
312
375
  validateFileType(filePath, allowedType, allowedFileTypes, maxFileSize)
@@ -369,6 +432,54 @@ class FileHandler
369
432
  }
370
433
  }
371
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
+
372
483
  }
373
484
 
374
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.16.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",
45
- "sanitize-html": "^2.16.0"
44
+ "multer": "2.0.0",
45
+ "sanitize-html": "^2.17.0"
46
46
  }
47
47
  }