@reldens/server-utils 0.26.0 → 0.27.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.
@@ -0,0 +1,29 @@
1
+ --- a/lib/app-server-factory.js
2
+ +++ b/lib/app-server-factory.js
3
+ @@ -141,10 +141,14 @@ class AppServerFactory
4
+
5
+ addHttpDomainsAsDevelopment()
6
+ {
7
+ + console.log('ENV VARS:', process.env.RELDENS_APP_HOST, process.env.RELDENS_PUBLIC_URL);
8
+ let hostDomain = this.extractDomainFromHttpUrl(process.env.RELDENS_APP_HOST);
9
+ let publicDomain = this.extractDomainFromHttpUrl(process.env.RELDENS_PUBLIC_URL);
10
+ + console.log('EXTRACTED DOMAINS:', hostDomain, publicDomain);
11
+ if(hostDomain && !this.developmentDomains.includes(hostDomain)){
12
+ this.developmentDomains.push(hostDomain);
13
+ + console.log('ADDED HOST DOMAIN:', hostDomain);
14
+ }
15
+ if(publicDomain && !this.developmentDomains.includes(publicDomain)){
16
+ this.developmentDomains.push(publicDomain);
17
+ + console.log('ADDED PUBLIC DOMAIN:', publicDomain);
18
+ }
19
+ + console.log('FINAL DEVELOPMENT DOMAINS:', this.developmentDomains);
20
+ }
21
+ @@ -154,6 +158,7 @@ class AppServerFactory
22
+ this.isDevelopmentMode = this.developmentModeDetector.detect({
23
+ developmentPatterns: this.developmentPatterns,
24
+ developmentEnvironments: this.developmentEnvironments,
25
+ developmentDomains: this.developmentDomains,
26
+ domains: this.domains
27
+ });
28
+ + console.log('DEVELOPMENT MODE DETECTED:', this.isDevelopmentMode);
29
+ }
@@ -27,40 +27,45 @@ class SecurityConfigurer
27
27
  if(!this.useHelmet){
28
28
  return;
29
29
  }
30
- let helmetOptions = {
30
+ let helmetOptions = this.setModeOptions({
31
31
  crossOriginEmbedderPolicy: false,
32
32
  crossOriginOpenerPolicy: false,
33
33
  crossOriginResourcePolicy: false,
34
34
  originAgentCluster: false
35
- };
35
+ }, config);
36
+ if(this.helmetConfig){
37
+ Object.assign(helmetOptions, this.helmetConfig);
38
+ }
39
+ app.use(helmet(helmetOptions));
40
+ }
41
+
42
+ setModeOptions(helmetOptions, config)
43
+ {
36
44
  if(this.isDevelopmentMode){
37
45
  helmetOptions.contentSecurityPolicy = false;
38
46
  helmetOptions.hsts = false;
39
47
  helmetOptions.noSniff = false;
40
- } else {
41
- helmetOptions.contentSecurityPolicy = {
42
- directives: {
43
- defaultSrc: ["'self'"],
44
- scriptSrc: ["'self'"],
45
- scriptSrcElem: ["'self'"],
46
- styleSrc: ["'self'", "'unsafe-inline'"],
47
- styleSrcElem: ["'self'", "'unsafe-inline'"],
48
- imgSrc: ["'self'", "data:", "https:"],
49
- fontSrc: ["'self'"],
50
- connectSrc: ["'self'"],
51
- frameAncestors: ["'none'"],
52
- baseUri: ["'self'"],
53
- formAction: ["'self'"]
54
- }
55
- };
56
- if(config.developmentExternalDomains){
57
- this.addExternalDomainsToCsp(helmetOptions.contentSecurityPolicy.directives, config.developmentExternalDomains);
58
- }
48
+ return helmetOptions;
59
49
  }
60
- if(this.helmetConfig){
61
- Object.assign(helmetOptions, this.helmetConfig);
50
+ helmetOptions.contentSecurityPolicy = {
51
+ directives: {
52
+ defaultSrc: ["'self'"],
53
+ scriptSrc: ["'self'"],
54
+ scriptSrcElem: ["'self'"],
55
+ styleSrc: ["'self'", "'unsafe-inline'"],
56
+ styleSrcElem: ["'self'", "'unsafe-inline'"],
57
+ imgSrc: ["'self'", "data:", "https:"],
58
+ fontSrc: ["'self'"],
59
+ connectSrc: ["'self'"],
60
+ frameAncestors: ["'none'"],
61
+ baseUri: ["'self'"],
62
+ formAction: ["'self'"]
63
+ }
64
+ };
65
+ if(config.developmentExternalDomains){
66
+ this.addExternalDomainsToCsp(helmetOptions.contentSecurityPolicy.directives, config.developmentExternalDomains);
62
67
  }
63
- app.use(helmet(helmetOptions));
68
+ return helmetOptions;
64
69
  }
65
70
 
66
71
  addExternalDomainsToCsp(directives, externalDomains)
@@ -1,499 +1,521 @@
1
- /**
2
- *
3
- * Reldens - AppServerFactory
4
- *
5
- */
6
-
7
- const { FileHandler } = require('./file-handler');
8
- const { DevelopmentModeDetector } = require('./app-server-factory/development-mode-detector');
9
- const { ProtocolEnforcer } = require('./app-server-factory/protocol-enforcer');
10
- const { SecurityConfigurer } = require('./app-server-factory/security-configurer');
11
- const { CorsConfigurer } = require('./app-server-factory/cors-configurer');
12
- const { RateLimitConfigurer } = require('./app-server-factory/rate-limit-configurer');
13
- const http = require('http');
14
- const https = require('https');
15
- const express = require('express');
16
- const bodyParser = require('body-parser');
17
- const session = require('express-session');
18
- const compression = require('compression');
19
-
20
- class AppServerFactory
21
- {
22
-
23
- constructor()
24
- {
25
- this.applicationFramework = express;
26
- this.bodyParser = bodyParser;
27
- this.session = session;
28
- this.compression = compression;
29
- this.appServer = false;
30
- this.app = express();
31
- this.useCors = true;
32
- this.useExpressJson = true;
33
- this.useUrlencoded = true;
34
- this.encoding = 'utf-8';
35
- this.useHttps = false;
36
- this.passphrase = '';
37
- this.httpsChain = '';
38
- this.keyPath = '';
39
- this.certPath = '';
40
- this.trustedProxy = '';
41
- this.windowMs = 60000;
42
- this.maxRequests = 30;
43
- this.applyKeyGenerator = false;
44
- this.jsonLimit = '1mb';
45
- this.urlencodedLimit = '1mb';
46
- this.useHelmet = true;
47
- this.helmetConfig = false;
48
- this.useXssProtection = true;
49
- this.globalRateLimit = 0;
50
- this.corsOrigin = '*';
51
- this.corsMethods = ['GET','POST'];
52
- this.corsHeaders = ['Content-Type','Authorization'];
53
- this.tooManyRequestsMessage = 'Too many requests, please try again later.';
54
- this.error = {};
55
- this.processErrorResponse = false;
56
- this.port = 3000;
57
- this.autoListen = false;
58
- this.domains = [];
59
- this.useVirtualHosts = false;
60
- this.defaultDomain = '';
61
- this.maxRequestSize = '10mb';
62
- this.sanitizeOptions = {allowedTags: [], allowedAttributes: {}};
63
- this.staticOptions = {
64
- maxAge: '1d',
65
- etag: true,
66
- lastModified: true,
67
- index: false,
68
- setHeaders: function(res){
69
- res.set('X-Content-Type-Options', 'nosniff');
70
- res.set('X-Frame-Options', 'DENY');
71
- }
72
- };
73
- this.isDevelopmentMode = false;
74
- this.developmentDomains = [];
75
- this.domainMapping = {};
76
- this.enforceProtocol = true;
77
- this.developmentPatterns = [
78
- 'localhost',
79
- '127.0.0.1',
80
- '.local',
81
- '.test',
82
- '.dev',
83
- '.staging'
84
- ];
85
- this.developmentEnvironments = ['development', 'dev', 'test'];
86
- this.developmentPorts = [3000, 8080, 8081];
87
- this.developmentMultiplier = 10;
88
- this.developmentModeDetector = new DevelopmentModeDetector();
89
- this.protocolEnforcer = new ProtocolEnforcer();
90
- this.securityConfigurer = new SecurityConfigurer();
91
- this.corsConfigurer = new CorsConfigurer();
92
- this.rateLimitConfigurer = new RateLimitConfigurer();
93
- this.useCompression = true;
94
- this.compressionOptions = {
95
- level: 6,
96
- threshold: 1024,
97
- filter: function(req, res){
98
- if(req.headers['x-no-compression']){
99
- return false;
100
- }
101
- return compression.filter(req, res);
102
- }
103
- };
104
- }
105
-
106
- createAppServer(appServerConfig)
107
- {
108
- if(appServerConfig){
109
- Object.assign(this, appServerConfig);
110
- }
111
- this.detectDevelopmentMode();
112
- this.setupDevelopmentConfiguration();
113
- this.setupProtocolEnforcement();
114
- this.setupSecurity();
115
- this.setupCompression();
116
- this.setupVirtualHosts();
117
- this.setupCors();
118
- this.setupRateLimiting();
119
- this.setupRequestParsing();
120
- this.setupTrustedProxy();
121
- this.appServer = this.createServer();
122
- if(!this.appServer){
123
- this.error = {message: 'Failed to create app server'};
124
- return false;
125
- }
126
- if(this.autoListen){
127
- this.listen();
128
- }
129
- return {app: this.app, appServer: this.appServer};
130
- }
131
-
132
- detectDevelopmentMode()
133
- {
134
- this.isDevelopmentMode = this.developmentModeDetector.detect({
135
- developmentPatterns: this.developmentPatterns,
136
- developmentEnvironments: this.developmentEnvironments,
137
- developmentDomains: this.developmentDomains,
138
- domains: this.domains
139
- });
140
- }
141
-
142
- setupDevelopmentConfiguration()
143
- {
144
- if(!this.isDevelopmentMode){
145
- return;
146
- }
147
- this.staticOptions.setHeaders = (res, path) => {
148
- res.set('X-Content-Type-Options', 'nosniff');
149
- res.set('X-Frame-Options', 'SAMEORIGIN');
150
- res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
151
- res.set('Pragma', 'no-cache');
152
- res.set('Expires', '0');
153
- };
154
- }
155
-
156
- setupProtocolEnforcement()
157
- {
158
- this.protocolEnforcer.setup(this.app, {
159
- isDevelopmentMode: this.isDevelopmentMode,
160
- useHttps: this.useHttps,
161
- enforceProtocol: this.enforceProtocol
162
- });
163
- }
164
-
165
- setupSecurity()
166
- {
167
- this.securityConfigurer.setupHelmet(this.app, {
168
- isDevelopmentMode: this.isDevelopmentMode,
169
- useHelmet: this.useHelmet,
170
- helmetConfig: this.helmetConfig,
171
- developmentExternalDomains: this.developmentExternalDomains
172
- });
173
- this.securityConfigurer.setupXssProtection(this.app, {
174
- useXssProtection: this.useXssProtection,
175
- sanitizeOptions: this.sanitizeOptions
176
- });
177
- }
178
-
179
- setupCompression()
180
- {
181
- if(!this.useCompression){
182
- return;
183
- }
184
- this.app.use(this.compression(this.compressionOptions));
185
- }
186
-
187
- setupCors()
188
- {
189
- this.corsConfigurer.setup(this.app, {
190
- isDevelopmentMode: this.isDevelopmentMode,
191
- useCors: this.useCors,
192
- corsOrigin: this.corsOrigin,
193
- corsMethods: this.corsMethods,
194
- corsHeaders: this.corsHeaders,
195
- domainMapping: this.domainMapping,
196
- developmentPorts: this.developmentPorts
197
- });
198
- }
199
-
200
- setupRateLimiting()
201
- {
202
- this.rateLimitConfigurer.setup(this.app, {
203
- isDevelopmentMode: this.isDevelopmentMode,
204
- globalRateLimit: this.globalRateLimit,
205
- windowMs: this.windowMs,
206
- maxRequests: this.maxRequests,
207
- developmentMultiplier: this.developmentMultiplier,
208
- applyKeyGenerator: this.applyKeyGenerator,
209
- tooManyRequestsMessage: this.tooManyRequestsMessage
210
- });
211
- }
212
-
213
- setupRequestParsing()
214
- {
215
- if(this.maxRequestSize){
216
- this.jsonLimit = this.maxRequestSize;
217
- this.urlencodedLimit = this.maxRequestSize;
218
- }
219
- if(this.useExpressJson){
220
- this.app.use(this.applicationFramework.json({
221
- limit: this.jsonLimit,
222
- verify: this.verifyContentTypeJson.bind(this)
223
- }));
224
- }
225
- if(this.useUrlencoded){
226
- this.app.use(this.bodyParser.urlencoded({
227
- extended: true,
228
- limit: this.urlencodedLimit
229
- }));
230
- }
231
- }
232
-
233
- setupTrustedProxy()
234
- {
235
- if('' !== this.trustedProxy){
236
- this.app.enable('trust proxy', this.trustedProxy);
237
- }
238
- }
239
-
240
- verifyContentTypeJson(req, res, buf)
241
- {
242
- let contentType = req.headers['content-type'] || '';
243
- if(
244
- 'POST' === req.method
245
- && 0 < buf.length
246
- && !contentType.includes('application/json')
247
- && !contentType.includes('multipart/form-data')
248
- ){
249
- this.error = {message: 'Invalid content-type for JSON request'};
250
- return false;
251
- }
252
- }
253
-
254
- setupVirtualHosts()
255
- {
256
- if(!this.useVirtualHosts || 0 === this.domains.length){
257
- return;
258
- }
259
- this.app.use((req, res, next) => {
260
- let hostname = req.get('host');
261
- if(!hostname){
262
- if(this.defaultDomain){
263
- req.domain = this.defaultDomain;
264
- return next();
265
- }
266
- this.error = {message: 'No hostname provided and no default domain configured'};
267
- return res.status(400).send('Bad Request');
268
- }
269
- let domain = this.findDomainConfig(hostname);
270
- if(!domain){
271
- if(this.defaultDomain){
272
- req.domain = this.defaultDomain;
273
- return next();
274
- }
275
- this.error = {message: 'Unknown domain: ' + hostname};
276
- return res.status(404).send('Domain not found');
277
- }
278
- req.domain = domain;
279
- next();
280
- });
281
- }
282
-
283
- findDomainConfig(hostname)
284
- {
285
- if(!hostname || 'string' !== typeof hostname){
286
- return false;
287
- }
288
- let cleanHostname = hostname.toLowerCase().trim();
289
- for(let i = 0; i < this.domains.length; i++){
290
- let domain = this.domains[i];
291
- if(domain.hostname === cleanHostname){
292
- return domain;
293
- }
294
- if(domain.aliases && domain.aliases.includes(cleanHostname)){
295
- return domain;
296
- }
297
- }
298
- return false;
299
- }
300
-
301
- createServer()
302
- {
303
- if(!this.useHttps){
304
- return http.createServer(this.app);
305
- }
306
- if(this.useVirtualHosts && 0 < this.domains.length){
307
- return this.createHttpsServerWithSNI();
308
- }
309
- return this.createSingleHttpsServer();
310
- }
311
-
312
- createSingleHttpsServer()
313
- {
314
- let key = FileHandler.readFile(this.keyPath, 'Key');
315
- if(!key){
316
- this.error = {message: 'Could not read SSL key file: ' + this.keyPath};
317
- return false;
318
- }
319
- let cert = FileHandler.readFile(this.certPath, 'Cert');
320
- if(!cert){
321
- this.error = {message: 'Could not read SSL certificate file: ' + this.certPath};
322
- return false;
323
- }
324
- let credentials = {key, cert, passphrase: this.passphrase};
325
- if('' !== this.httpsChain){
326
- let ca = FileHandler.readFile(this.httpsChain, 'Certificate Authority');
327
- if(ca){
328
- credentials.ca = ca;
329
- }
330
- }
331
- return https.createServer(credentials, this.app);
332
- }
333
-
334
- createHttpsServerWithSNI()
335
- {
336
- let defaultCredentials = this.loadDefaultCredentials();
337
- if(!defaultCredentials){
338
- return false;
339
- }
340
- let httpsOptions = Object.assign({}, defaultCredentials);
341
- httpsOptions.SNICallback = (hostname, callback) => {
342
- let domain = this.findDomainConfig(hostname);
343
- if(!domain || !domain.keyPath || !domain.certPath){
344
- return callback(null, null);
345
- }
346
- let key = FileHandler.readFile(domain.keyPath, 'Domain Key');
347
- if(!key){
348
- this.error = {message: 'Could not read domain SSL key: '+domain.keyPath};
349
- return callback(null, null);
350
- }
351
- let cert = FileHandler.readFile(domain.certPath, 'Domain Cert');
352
- if(!cert){
353
- this.error = {message: 'Could not read domain SSL certificate: '+domain.certPath};
354
- return callback(null, null);
355
- }
356
- let ctx = require('tls').createSecureContext({key, cert});
357
- callback(null, ctx);
358
- };
359
- return https.createServer(httpsOptions, this.app);
360
- }
361
-
362
- loadDefaultCredentials()
363
- {
364
- let key = FileHandler.readFile(this.keyPath, 'Default Key');
365
- if(!key){
366
- this.error = {message: 'Could not read default SSL key file: '+this.keyPath};
367
- return false;
368
- }
369
- let cert = FileHandler.readFile(this.certPath, 'Default Cert');
370
- if(!cert){
371
- this.error = {message: 'Could not read default SSL certificate file: '+this.certPath};
372
- return false;
373
- }
374
- return {key, cert, passphrase: this.passphrase};
375
- }
376
-
377
- listen(port)
378
- {
379
- let listenPort = port || this.port;
380
- if(!this.appServer){
381
- this.error = {message: 'Cannot listen: app server not created'};
382
- return false;
383
- }
384
- this.appServer.listen(listenPort);
385
- return true;
386
- }
387
-
388
- async enableServeHome(app, homePageLoadCallback)
389
- {
390
- let limiter = this.rateLimitConfigurer.createHomeLimiter();
391
- app.post('/', limiter);
392
- app.post('/', async (req, res, next) => {
393
- if('/' === req._parsedUrl.pathname){
394
- return res.redirect('/');
395
- }
396
- next();
397
- });
398
- app.get('/', limiter);
399
- app.get('/', async (req, res, next) => {
400
- if('/' === req._parsedUrl.pathname){
401
- if('function' !== typeof homePageLoadCallback){
402
- let errorMessage = 'Homepage contents could not be loaded.';
403
- if('function' === typeof this.processErrorResponse){
404
- return this.processErrorResponse(500, errorMessage, req, res);
405
- }
406
- return res.status(500).send(errorMessage);
407
- }
408
- let homepageContent = await homePageLoadCallback(req);
409
- if(!homepageContent){
410
- let message = 'Error loading homepage content';
411
- this.error = {message};
412
- if('function' === typeof this.processErrorResponse){
413
- return this.processErrorResponse(500, message, req, res);
414
- }
415
- return res.status(500).send(message);
416
- }
417
- return res.send(homepageContent);
418
- }
419
- next();
420
- });
421
- }
422
-
423
- async serveStatics(app, statics)
424
- {
425
- app.use(this.applicationFramework.static(statics, this.staticOptions));
426
- return true;
427
- }
428
-
429
- async serveStaticsPath(app, staticsPath, statics)
430
- {
431
- app.use(staticsPath, this.applicationFramework.static(statics, this.staticOptions));
432
- return true;
433
- }
434
-
435
- addDomain(domainConfig)
436
- {
437
- if(!domainConfig || !domainConfig.hostname){
438
- this.error = {message: 'Domain configuration missing hostname'};
439
- return false;
440
- }
441
- if('string' !== typeof domainConfig.hostname){
442
- this.error = {message: 'Domain hostname must be a string'};
443
- return false;
444
- }
445
- this.domains.push(domainConfig);
446
- return true;
447
- }
448
-
449
- addDevelopmentDomain(domain)
450
- {
451
- if(!domain || 'string' !== typeof domain){
452
- return false;
453
- }
454
- this.developmentDomains.push(domain);
455
- return true;
456
- }
457
-
458
- setDomainMapping(mapping)
459
- {
460
- if(!mapping || 'object' !== typeof mapping){
461
- return false;
462
- }
463
- this.domainMapping = mapping;
464
- return true;
465
- }
466
-
467
- async close()
468
- {
469
- if(!this.appServer){
470
- return true;
471
- }
472
- return this.appServer.close();
473
- }
474
-
475
- enableCSP(cspOptions)
476
- {
477
- return this.securityConfigurer.enableCSP(this.app, cspOptions);
478
- }
479
-
480
- validateInput(input, type)
481
- {
482
- if('string' !== typeof input){
483
- return false;
484
- }
485
- let patterns = {
486
- email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
487
- username: /^[a-zA-Z0-9_-]{3,30}$/,
488
- strongPassword: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
489
- alphanumeric: /^[a-zA-Z0-9]+$/,
490
- numeric: /^\d+$/,
491
- hexColor: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
492
- 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]?)$/
493
- };
494
- return patterns[type] ? patterns[type].test(input) : false;
495
- }
496
-
497
- }
498
-
499
- module.exports.AppServerFactory = AppServerFactory;
1
+ /**
2
+ *
3
+ * Reldens - AppServerFactory
4
+ *
5
+ */
6
+
7
+ const { FileHandler } = require('./file-handler');
8
+ const { DevelopmentModeDetector } = require('./app-server-factory/development-mode-detector');
9
+ const { ProtocolEnforcer } = require('./app-server-factory/protocol-enforcer');
10
+ const { SecurityConfigurer } = require('./app-server-factory/security-configurer');
11
+ const { CorsConfigurer } = require('./app-server-factory/cors-configurer');
12
+ const { RateLimitConfigurer } = require('./app-server-factory/rate-limit-configurer');
13
+ const http = require('http');
14
+ const https = require('https');
15
+ const express = require('express');
16
+ const bodyParser = require('body-parser');
17
+ const session = require('express-session');
18
+ const compression = require('compression');
19
+
20
+ class AppServerFactory
21
+ {
22
+
23
+ constructor()
24
+ {
25
+ this.applicationFramework = express;
26
+ this.bodyParser = bodyParser;
27
+ this.session = session;
28
+ this.compression = compression;
29
+ this.appServer = false;
30
+ this.app = express();
31
+ this.useCors = true;
32
+ this.useExpressJson = true;
33
+ this.useUrlencoded = true;
34
+ this.encoding = 'utf-8';
35
+ this.useHttps = false;
36
+ this.passphrase = '';
37
+ this.httpsChain = '';
38
+ this.keyPath = '';
39
+ this.certPath = '';
40
+ this.trustedProxy = '';
41
+ this.windowMs = 60000;
42
+ this.maxRequests = 30;
43
+ this.applyKeyGenerator = false;
44
+ this.jsonLimit = '1mb';
45
+ this.urlencodedLimit = '1mb';
46
+ this.useHelmet = true;
47
+ this.helmetConfig = false;
48
+ this.useXssProtection = true;
49
+ this.globalRateLimit = 0;
50
+ this.corsOrigin = '*';
51
+ this.corsMethods = ['GET','POST'];
52
+ this.corsHeaders = ['Content-Type','Authorization'];
53
+ this.tooManyRequestsMessage = 'Too many requests, please try again later.';
54
+ this.error = {};
55
+ this.processErrorResponse = false;
56
+ this.port = 3000;
57
+ this.autoListen = false;
58
+ this.domains = [];
59
+ this.useVirtualHosts = false;
60
+ this.defaultDomain = '';
61
+ this.maxRequestSize = '10mb';
62
+ this.sanitizeOptions = {allowedTags: [], allowedAttributes: {}};
63
+ this.staticOptions = {
64
+ maxAge: '1d',
65
+ etag: true,
66
+ lastModified: true,
67
+ index: false,
68
+ setHeaders: function(res){
69
+ res.set('X-Content-Type-Options', 'nosniff');
70
+ res.set('X-Frame-Options', 'DENY');
71
+ }
72
+ };
73
+ this.isDevelopmentMode = false;
74
+ this.developmentDomains = [];
75
+ this.domainMapping = {};
76
+ this.enforceProtocol = true;
77
+ this.developmentPatterns = [
78
+ 'localhost',
79
+ '127.0.0.1',
80
+ '.local',
81
+ '.test',
82
+ '.dev',
83
+ '.staging'
84
+ ];
85
+ this.developmentEnvironments = ['development', 'dev', 'test'];
86
+ this.developmentPorts = [3000, 8080, 8081];
87
+ this.developmentMultiplier = 10;
88
+ this.developmentExternalDomains = {};
89
+ this.developmentModeDetector = new DevelopmentModeDetector();
90
+ this.protocolEnforcer = new ProtocolEnforcer();
91
+ this.securityConfigurer = new SecurityConfigurer();
92
+ this.corsConfigurer = new CorsConfigurer();
93
+ this.rateLimitConfigurer = new RateLimitConfigurer();
94
+ this.useCompression = true;
95
+ this.compressionOptions = {
96
+ level: 6,
97
+ threshold: 1024,
98
+ filter: function(req, res){
99
+ if(req.headers['x-no-compression']){
100
+ return false;
101
+ }
102
+ return compression.filter(req, res);
103
+ }
104
+ };
105
+ }
106
+
107
+ createAppServer(appServerConfig)
108
+ {
109
+ if(appServerConfig){
110
+ Object.assign(this, appServerConfig);
111
+ }
112
+ this.addHttpDomainsAsDevelopment();
113
+ this.detectDevelopmentMode();
114
+ this.setupDevelopmentConfiguration();
115
+ this.setupProtocolEnforcement();
116
+ this.setupSecurity();
117
+ this.setupCompression();
118
+ this.setupVirtualHosts();
119
+ this.setupCors();
120
+ this.setupRateLimiting();
121
+ this.setupRequestParsing();
122
+ this.setupTrustedProxy();
123
+ this.appServer = this.createServer();
124
+ if(!this.appServer){
125
+ this.error = {message: 'Failed to create app server'};
126
+ return false;
127
+ }
128
+ if(this.autoListen){
129
+ this.listen();
130
+ }
131
+ return {app: this.app, appServer: this.appServer};
132
+ }
133
+
134
+ extractDomainFromHttpUrl(url)
135
+ {
136
+ if(!url || (!url.startsWith('http://') && !url.startsWith('https://'))){
137
+ return false;
138
+ }
139
+ return url.replace(/^https?:\/\//, '').split(':')[0];
140
+ }
141
+
142
+ addHttpDomainsAsDevelopment()
143
+ {
144
+ let hostDomain = this.extractDomainFromHttpUrl(process.env.RELDENS_APP_HOST);
145
+ let publicDomain = this.extractDomainFromHttpUrl(process.env.RELDENS_PUBLIC_URL);
146
+ if(hostDomain && !this.developmentDomains.includes(hostDomain)){
147
+ this.developmentDomains.push(hostDomain);
148
+ }
149
+ if(publicDomain && !this.developmentDomains.includes(publicDomain)){
150
+ this.developmentDomains.push(publicDomain);
151
+ }
152
+ }
153
+
154
+ detectDevelopmentMode()
155
+ {
156
+ this.isDevelopmentMode = this.developmentModeDetector.detect({
157
+ developmentPatterns: this.developmentPatterns,
158
+ developmentEnvironments: this.developmentEnvironments,
159
+ developmentDomains: this.developmentDomains,
160
+ domains: this.domains
161
+ });
162
+ }
163
+
164
+ setupDevelopmentConfiguration()
165
+ {
166
+ if(!this.isDevelopmentMode){
167
+ return;
168
+ }
169
+ this.staticOptions.setHeaders = (res) => {
170
+ res.set('X-Content-Type-Options', 'nosniff');
171
+ res.set('X-Frame-Options', 'SAMEORIGIN');
172
+ res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
173
+ res.set('Pragma', 'no-cache');
174
+ res.set('Expires', '0');
175
+ };
176
+ }
177
+
178
+ setupProtocolEnforcement()
179
+ {
180
+ this.protocolEnforcer.setup(this.app, {
181
+ isDevelopmentMode: this.isDevelopmentMode,
182
+ useHttps: this.useHttps,
183
+ enforceProtocol: this.enforceProtocol
184
+ });
185
+ }
186
+
187
+ setupSecurity()
188
+ {
189
+ this.securityConfigurer.setupHelmet(this.app, {
190
+ isDevelopmentMode: this.isDevelopmentMode,
191
+ useHelmet: this.useHelmet,
192
+ helmetConfig: this.helmetConfig,
193
+ developmentExternalDomains: this.developmentExternalDomains
194
+ });
195
+ this.securityConfigurer.setupXssProtection(this.app, {
196
+ useXssProtection: this.useXssProtection,
197
+ sanitizeOptions: this.sanitizeOptions
198
+ });
199
+ }
200
+
201
+ setupCompression()
202
+ {
203
+ if(!this.useCompression){
204
+ return;
205
+ }
206
+ this.app.use(this.compression(this.compressionOptions));
207
+ }
208
+
209
+ setupCors()
210
+ {
211
+ this.corsConfigurer.setup(this.app, {
212
+ isDevelopmentMode: this.isDevelopmentMode,
213
+ useCors: this.useCors,
214
+ corsOrigin: this.corsOrigin,
215
+ corsMethods: this.corsMethods,
216
+ corsHeaders: this.corsHeaders,
217
+ domainMapping: this.domainMapping,
218
+ developmentPorts: this.developmentPorts
219
+ });
220
+ }
221
+
222
+ setupRateLimiting()
223
+ {
224
+ this.rateLimitConfigurer.setup(this.app, {
225
+ isDevelopmentMode: this.isDevelopmentMode,
226
+ globalRateLimit: this.globalRateLimit,
227
+ windowMs: this.windowMs,
228
+ maxRequests: this.maxRequests,
229
+ developmentMultiplier: this.developmentMultiplier,
230
+ applyKeyGenerator: this.applyKeyGenerator,
231
+ tooManyRequestsMessage: this.tooManyRequestsMessage
232
+ });
233
+ }
234
+
235
+ setupRequestParsing()
236
+ {
237
+ if(this.maxRequestSize){
238
+ this.jsonLimit = this.maxRequestSize;
239
+ this.urlencodedLimit = this.maxRequestSize;
240
+ }
241
+ if(this.useExpressJson){
242
+ this.app.use(this.applicationFramework.json({
243
+ limit: this.jsonLimit,
244
+ verify: this.verifyContentTypeJson.bind(this)
245
+ }));
246
+ }
247
+ if(this.useUrlencoded){
248
+ this.app.use(this.bodyParser.urlencoded({
249
+ extended: true,
250
+ limit: this.urlencodedLimit
251
+ }));
252
+ }
253
+ }
254
+
255
+ setupTrustedProxy()
256
+ {
257
+ if('' !== this.trustedProxy){
258
+ this.app.enable('trust proxy', this.trustedProxy);
259
+ }
260
+ }
261
+
262
+ verifyContentTypeJson(req, res, buf)
263
+ {
264
+ let contentType = req.headers['content-type'] || '';
265
+ if(
266
+ 'POST' === req.method
267
+ && 0 < buf.length
268
+ && !contentType.includes('application/json')
269
+ && !contentType.includes('multipart/form-data')
270
+ ){
271
+ this.error = {message: 'Invalid content-type for JSON request'};
272
+ return false;
273
+ }
274
+ }
275
+
276
+ setupVirtualHosts()
277
+ {
278
+ if(!this.useVirtualHosts || 0 === this.domains.length){
279
+ return;
280
+ }
281
+ this.app.use((req, res, next) => {
282
+ let hostname = req.get('host');
283
+ if(!hostname){
284
+ if(this.defaultDomain){
285
+ req.domain = this.defaultDomain;
286
+ return next();
287
+ }
288
+ this.error = {message: 'No hostname provided and no default domain configured'};
289
+ return res.status(400).send('Bad Request');
290
+ }
291
+ let domain = this.findDomainConfig(hostname);
292
+ if(!domain){
293
+ if(this.defaultDomain){
294
+ req.domain = this.defaultDomain;
295
+ return next();
296
+ }
297
+ this.error = {message: 'Unknown domain: ' + hostname};
298
+ return res.status(404).send('Domain not found');
299
+ }
300
+ req.domain = domain;
301
+ next();
302
+ });
303
+ }
304
+
305
+ findDomainConfig(hostname)
306
+ {
307
+ if(!hostname || 'string' !== typeof hostname){
308
+ return false;
309
+ }
310
+ let cleanHostname = hostname.toLowerCase().trim();
311
+ for(let i = 0; i < this.domains.length; i++){
312
+ let domain = this.domains[i];
313
+ if(domain.hostname === cleanHostname){
314
+ return domain;
315
+ }
316
+ if(domain.aliases && domain.aliases.includes(cleanHostname)){
317
+ return domain;
318
+ }
319
+ }
320
+ return false;
321
+ }
322
+
323
+ createServer()
324
+ {
325
+ if(!this.useHttps){
326
+ return http.createServer(this.app);
327
+ }
328
+ if(this.useVirtualHosts && 0 < this.domains.length){
329
+ return this.createHttpsServerWithSNI();
330
+ }
331
+ return this.createSingleHttpsServer();
332
+ }
333
+
334
+ createSingleHttpsServer()
335
+ {
336
+ let key = FileHandler.readFile(this.keyPath, 'Key');
337
+ if(!key){
338
+ this.error = {message: 'Could not read SSL key file: ' + this.keyPath};
339
+ return false;
340
+ }
341
+ let cert = FileHandler.readFile(this.certPath, 'Cert');
342
+ if(!cert){
343
+ this.error = {message: 'Could not read SSL certificate file: ' + this.certPath};
344
+ return false;
345
+ }
346
+ let credentials = {key, cert, passphrase: this.passphrase};
347
+ if('' !== this.httpsChain){
348
+ let ca = FileHandler.readFile(this.httpsChain, 'Certificate Authority');
349
+ if(ca){
350
+ credentials.ca = ca;
351
+ }
352
+ }
353
+ return https.createServer(credentials, this.app);
354
+ }
355
+
356
+ createHttpsServerWithSNI()
357
+ {
358
+ let defaultCredentials = this.loadDefaultCredentials();
359
+ if(!defaultCredentials){
360
+ return false;
361
+ }
362
+ let httpsOptions = Object.assign({}, defaultCredentials);
363
+ httpsOptions.SNICallback = (hostname, callback) => {
364
+ let domain = this.findDomainConfig(hostname);
365
+ if(!domain || !domain.keyPath || !domain.certPath){
366
+ return callback(null, null);
367
+ }
368
+ let key = FileHandler.readFile(domain.keyPath, 'Domain Key');
369
+ if(!key){
370
+ this.error = {message: 'Could not read domain SSL key: '+domain.keyPath};
371
+ return callback(null, null);
372
+ }
373
+ let cert = FileHandler.readFile(domain.certPath, 'Domain Cert');
374
+ if(!cert){
375
+ this.error = {message: 'Could not read domain SSL certificate: '+domain.certPath};
376
+ return callback(null, null);
377
+ }
378
+ let ctx = require('tls').createSecureContext({key, cert});
379
+ callback(null, ctx);
380
+ };
381
+ return https.createServer(httpsOptions, this.app);
382
+ }
383
+
384
+ loadDefaultCredentials()
385
+ {
386
+ let key = FileHandler.readFile(this.keyPath, 'Default Key');
387
+ if(!key){
388
+ this.error = {message: 'Could not read default SSL key file: '+this.keyPath};
389
+ return false;
390
+ }
391
+ let cert = FileHandler.readFile(this.certPath, 'Default Cert');
392
+ if(!cert){
393
+ this.error = {message: 'Could not read default SSL certificate file: '+this.certPath};
394
+ return false;
395
+ }
396
+ return {key, cert, passphrase: this.passphrase};
397
+ }
398
+
399
+ listen(port)
400
+ {
401
+ let listenPort = port || this.port;
402
+ if(!this.appServer){
403
+ this.error = {message: 'Cannot listen: app server not created'};
404
+ return false;
405
+ }
406
+ this.appServer.listen(listenPort);
407
+ return true;
408
+ }
409
+
410
+ async enableServeHome(app, homePageLoadCallback)
411
+ {
412
+ let limiter = this.rateLimitConfigurer.createHomeLimiter();
413
+ app.post('/', limiter);
414
+ app.post('/', async (req, res, next) => {
415
+ if('/' === req._parsedUrl.pathname){
416
+ return res.redirect('/');
417
+ }
418
+ next();
419
+ });
420
+ app.get('/', limiter);
421
+ app.get('/', async (req, res, next) => {
422
+ if('/' === req._parsedUrl.pathname){
423
+ if('function' !== typeof homePageLoadCallback){
424
+ let errorMessage = 'Homepage contents could not be loaded.';
425
+ if('function' === typeof this.processErrorResponse){
426
+ return this.processErrorResponse(500, errorMessage, req, res);
427
+ }
428
+ return res.status(500).send(errorMessage);
429
+ }
430
+ let homepageContent = await homePageLoadCallback(req);
431
+ if(!homepageContent){
432
+ let message = 'Error loading homepage content';
433
+ this.error = {message};
434
+ if('function' === typeof this.processErrorResponse){
435
+ return this.processErrorResponse(500, message, req, res);
436
+ }
437
+ return res.status(500).send(message);
438
+ }
439
+ return res.send(homepageContent);
440
+ }
441
+ next();
442
+ });
443
+ }
444
+
445
+ async serveStatics(app, statics)
446
+ {
447
+ app.use(this.applicationFramework.static(statics, this.staticOptions));
448
+ return true;
449
+ }
450
+
451
+ async serveStaticsPath(app, staticsPath, statics)
452
+ {
453
+ app.use(staticsPath, this.applicationFramework.static(statics, this.staticOptions));
454
+ return true;
455
+ }
456
+
457
+ addDomain(domainConfig)
458
+ {
459
+ if(!domainConfig || !domainConfig.hostname){
460
+ this.error = {message: 'Domain configuration missing hostname'};
461
+ return false;
462
+ }
463
+ if('string' !== typeof domainConfig.hostname){
464
+ this.error = {message: 'Domain hostname must be a string'};
465
+ return false;
466
+ }
467
+ this.domains.push(domainConfig);
468
+ return true;
469
+ }
470
+
471
+ addDevelopmentDomain(domain)
472
+ {
473
+ if(!domain || 'string' !== typeof domain){
474
+ return false;
475
+ }
476
+ this.developmentDomains.push(domain);
477
+ return true;
478
+ }
479
+
480
+ setDomainMapping(mapping)
481
+ {
482
+ if(!mapping || 'object' !== typeof mapping){
483
+ return false;
484
+ }
485
+ this.domainMapping = mapping;
486
+ return true;
487
+ }
488
+
489
+ async close()
490
+ {
491
+ if(!this.appServer){
492
+ return true;
493
+ }
494
+ return this.appServer.close();
495
+ }
496
+
497
+ enableCSP(cspOptions)
498
+ {
499
+ return this.securityConfigurer.enableCSP(this.app, cspOptions);
500
+ }
501
+
502
+ validateInput(input, type)
503
+ {
504
+ if('string' !== typeof input){
505
+ return false;
506
+ }
507
+ let patterns = {
508
+ email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
509
+ username: /^[a-zA-Z0-9_-]{3,30}$/,
510
+ strongPassword: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
511
+ alphanumeric: /^[a-zA-Z0-9]+$/,
512
+ numeric: /^\d+$/,
513
+ hexColor: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
514
+ 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]?)$/
515
+ };
516
+ return patterns[type] ? patterns[type].test(input) : false;
517
+ }
518
+
519
+ }
520
+
521
+ module.exports.AppServerFactory = AppServerFactory;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reldens/server-utils",
3
3
  "scope": "@reldens",
4
- "version": "0.26.0",
4
+ "version": "0.27.0",
5
5
  "description": "Reldens - Server Utils",
6
6
  "author": "Damian A. Pastorini",
7
7
  "license": "MIT",
@@ -35,14 +35,14 @@
35
35
  "url": "https://github.com/damian-pastorini/reldens-server-utils/issues"
36
36
  },
37
37
  "dependencies": {
38
- "body-parser": "^2.2.0",
39
- "compression": "^1.8.1",
40
- "cors": "^2.8.5",
41
- "express": "^4.21.2",
42
- "express-rate-limit": "^8.0.1",
43
- "express-session": "^1.18.2",
44
- "helmet": "^8.1.0",
45
- "multer": "^2.0.2",
46
- "sanitize-html": "^2.17.0"
38
+ "body-parser": "2.2.0",
39
+ "compression": "1.8.1",
40
+ "cors": "2.8.5",
41
+ "express": "4.21.2",
42
+ "express-rate-limit": "8.1.0",
43
+ "express-session": "1.18.2",
44
+ "helmet": "8.1.0",
45
+ "multer": "2.0.2",
46
+ "sanitize-html": "2.17.0"
47
47
  }
48
48
  }