@reldens/server-utils
Version:
Reldens - Server Utils
438 lines (415 loc) • 14.8 kB
JavaScript
/**
*
* Reldens - AppServerFactory
*
*/
const { FileHandler } = require('./file-handler');
const http = require('http');
const https = require('https');
const express = require('express');
const bodyParser = require('body-parser');
const session = require('express-session');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const helmet = require('helmet');
const sanitizeHtml = require('sanitize-html');
class AppServerFactory
{
constructor()
{
this.applicationFramework = express;
this.bodyParser = bodyParser;
this.session = session;
this.appServer = false;
this.app = express();
this.rateLimit = rateLimit;
this.useCors = true;
this.useExpressJson = true;
this.useUrlencoded = true;
this.encoding = 'utf-8';
this.useHttps = false;
this.passphrase = '';
this.httpsChain = '';
this.keyPath = '';
this.certPath = '';
this.trustedProxy = '';
this.windowMs = 60000;
this.maxRequests = 30;
this.applyKeyGenerator = false;
this.jsonLimit = '1mb';
this.urlencodedLimit = '1mb';
this.useHelmet = true;
this.helmetConfig = false;
this.useXssProtection = true;
this.globalRateLimit = 0;
this.corsOrigin = '*';
this.corsMethods = ['GET','POST'];
this.corsHeaders = ['Content-Type','Authorization'];
this.tooManyRequestsMessage = 'Too many requests, please try again later.';
this.error = {};
this.processErrorResponse = false;
this.port = 3000;
this.autoListen = false;
this.domains = [];
this.useVirtualHosts = false;
this.defaultDomain = '';
this.maxRequestSize = '10mb';
this.sanitizeOptions = {allowedTags: [], allowedAttributes: {}};
this.staticOptions = {
maxAge: '1d',
etag: true,
lastModified: true,
index: false,
setHeaders: function(res){
res.set('X-Content-Type-Options', 'nosniff');
res.set('X-Frame-Options', 'DENY');
}
};
}
createAppServer(appServerConfig)
{
if(appServerConfig){
Object.assign(this, appServerConfig);
}
if(this.useHelmet){
this.app.use(this.helmetConfig ? helmet(this.helmetConfig) : helmet());
}
if(this.useVirtualHosts){
this.setupVirtualHosts();
}
if(this.useCors){
let corsOptions = {
origin: this.corsOrigin,
methods: this.corsMethods,
allowedHeaders: this.corsHeaders
};
this.app.use(cors(corsOptions));
}
if(this.globalRateLimit){
let limiterParams = {
windowMs: this.windowMs,
max: this.maxRequests,
standardHeaders: true,
legacyHeaders: false,
message: this.tooManyRequestsMessage
};
if(this.applyKeyGenerator){
limiterParams.keyGenerator = function(req){
return req.ip;
};
}
this.app.use(this.rateLimit(limiterParams));
}
if(this.useXssProtection){
this.app.use((req, res, next) => {
if(!req.body){
return next();
}
if('object' === typeof req.body){
this.sanitizeRequestBody(req.body);
}
next();
});
}
if(this.useExpressJson){
this.app.use(this.applicationFramework.json({
limit: this.jsonLimit,
verify: this.verifyContentTypeJson.bind(this)
}));
}
if(this.useUrlencoded){
this.app.use(this.bodyParser.urlencoded({
extended: true,
limit: this.urlencodedLimit
}));
}
if('' !== this.trustedProxy){
this.app.enable('trust proxy', this.trustedProxy);
}
this.appServer = this.createServer();
if(!this.appServer){
this.error = {message: 'Failed to create app server'};
return false;
}
if(this.autoListen){
this.listen();
}
return {app: this.app, appServer: this.appServer};
}
sanitizeRequestBody(body)
{
let bodyKeys = Object.keys(body);
for(let i = 0; i < bodyKeys.length; i++){
let key = bodyKeys[i];
if('string' === typeof body[key]){
body[key] = sanitizeHtml(body[key], this.sanitizeOptions);
continue;
}
if('object' === typeof body[key] && null !== body[key]){
this.sanitizeRequestBody(body[key]);
}
}
}
verifyContentTypeJson(req, res, buf)
{
let contentType = req.headers['content-type'] || '';
if(
'POST' === req.method
&& 0 < buf.length
&& !contentType.includes('application/json')
&& !contentType.includes('multipart/form-data')
){
this.error = {message: 'Invalid content-type for JSON request'};
return false;
}
}
setupVirtualHosts()
{
if(0 === this.domains.length){
return;
}
this.app.use((req, res, next) => {
let hostname = req.get('host');
if(!hostname){
if(this.defaultDomain){
req.domain = this.defaultDomain;
return next();
}
this.error = {message: 'No hostname provided and no default domain configured'};
return res.status(400).send('Bad Request');
}
let domain = this.findDomainConfig(hostname);
if(!domain){
if(this.defaultDomain){
req.domain = this.defaultDomain;
return next();
}
this.error = {message: 'Unknown domain: ' + hostname};
return res.status(404).send('Domain not found');
}
req.domain = domain;
next();
});
}
findDomainConfig(hostname)
{
if(!hostname || 'string' !== typeof hostname){
return false;
}
let cleanHostname = hostname.toLowerCase().trim();
for(let i = 0; i < this.domains.length; i++){
let domain = this.domains[i];
if(domain.hostname === cleanHostname){
return domain;
}
if(domain.aliases && domain.aliases.includes(cleanHostname)){
return domain;
}
}
return false;
}
createServer()
{
if(!this.useHttps){
return http.createServer(this.app);
}
if(this.useVirtualHosts && 0 < this.domains.length){
return this.createHttpsServerWithSNI();
}
return this.createSingleHttpsServer();
}
createSingleHttpsServer()
{
let key = FileHandler.readFile(this.keyPath, 'Key');
if(!key){
this.error = {message: 'Could not read SSL key file: ' + this.keyPath};
return false;
}
let cert = FileHandler.readFile(this.certPath, 'Cert');
if(!cert){
this.error = {message: 'Could not read SSL certificate file: ' + this.certPath};
return false;
}
let credentials = {key, cert, passphrase: this.passphrase};
if('' !== this.httpsChain){
let ca = FileHandler.readFile(this.httpsChain, 'Certificate Authority');
if(ca){
credentials.ca = ca;
}
}
return https.createServer(credentials, this.app);
}
createHttpsServerWithSNI()
{
let defaultCredentials = this.loadDefaultCredentials();
if(!defaultCredentials){
return false;
}
let httpsOptions = Object.assign({}, defaultCredentials);
httpsOptions.SNICallback = (hostname, callback) => {
let domain = this.findDomainConfig(hostname);
if(!domain || !domain.keyPath || !domain.certPath){
return callback(null, null);
}
let key = FileHandler.readFile(domain.keyPath, 'Domain Key');
if(!key){
this.error = {message: 'Could not read domain SSL key: '+domain.keyPath};
return callback(null, null);
}
let cert = FileHandler.readFile(domain.certPath, 'Domain Cert');
if(!cert){
this.error = {message: 'Could not read domain SSL certificate: '+domain.certPath};
return callback(null, null);
}
let ctx = require('tls').createSecureContext({key, cert});
callback(null, ctx);
};
return https.createServer(httpsOptions, this.app);
}
loadDefaultCredentials()
{
let key = FileHandler.readFile(this.keyPath, 'Default Key');
if(!key){
this.error = {message: 'Could not read default SSL key file: '+this.keyPath};
return false;
}
let cert = FileHandler.readFile(this.certPath, 'Default Cert');
if(!cert){
this.error = {message: 'Could not read default SSL certificate file: '+this.certPath};
return false;
}
return {key, cert, passphrase: this.passphrase};
}
listen(port)
{
let listenPort = port || this.port;
if(!this.appServer){
this.error = {message: 'Cannot listen: app server not created'};
return false;
}
this.appServer.listen(listenPort);
return true;
}
async enableServeHome(app, homePageLoadCallback)
{
let limiterParams = {
windowMs: this.windowMs,
max: this.maxRequests,
standardHeaders: true,
legacyHeaders: false
};
if(this.applyKeyGenerator){
limiterParams.keyGenerator = function(req){
return req.ip;
};
}
let limiter = this.rateLimit(limiterParams);
app.post('/', limiter);
app.post('/', async (req, res, next) => {
if('/' === req._parsedUrl.pathname){
return res.redirect('/');
}
next();
});
app.get('/', limiter);
app.get('/', async (req, res, next) => {
if('/' === req._parsedUrl.pathname){
if('function' !== typeof homePageLoadCallback){
let errorMessage = 'Homepage contents could not be loaded.';
if('function' === typeof this.processErrorResponse){
return this.processErrorResponse(500, errorMessage, req, res);
}
return res.status(500).send(errorMessage);
}
let homepageContent = await homePageLoadCallback(req);
if(!homepageContent){
let message = 'Error loading homepage content';
this.error = {message};
if('function' === typeof this.processErrorResponse){
return this.processErrorResponse(500, message, req, res);
}
return res.status(500).send(message);
}
return res.send(homepageContent);
}
next();
});
}
async serveStatics(app, statics)
{
app.use(this.applicationFramework.static(statics, this.staticOptions));
return true;
}
async serveStaticsPath(app, staticsPath, statics)
{
app.use(staticsPath, this.applicationFramework.static(statics, this.staticOptions));
return true;
}
addDomain(domainConfig)
{
if(!domainConfig || !domainConfig.hostname){
this.error = {message: 'Domain configuration missing hostname'};
return false;
}
if('string' !== typeof domainConfig.hostname){
this.error = {message: 'Domain hostname must be a string'};
return false;
}
this.domains.push(domainConfig);
return true;
}
async close()
{
if(!this.appServer){
return true;
}
return this.appServer.close();
}
enableCSP(cspOptions)
{
let defaults = {
'default-src': ["'self'"],
'script-src': ["'self'"],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", "data:", "https:"],
'font-src': ["'self'"],
'connect-src': ["'self'"],
'frame-ancestors': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"]
};
let csp = Object.assign({}, defaults, cspOptions);
let policyString = '';
let keys = Object.keys(csp);
for(let i = 0; i < keys.length; i++){
let directive = keys[i];
let sources = csp[directive];
if(0 < i){
policyString += '; ';
}
policyString += directive + ' ' + sources.join(' ');
}
this.app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', policyString);
next();
});
return true;
}
validateInput(input, type)
{
if('string' !== typeof input){
return false;
}
let patterns = {
email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
username: /^[a-zA-Z0-9_-]{3,30}$/,
strongPassword: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
alphanumeric: /^[a-zA-Z0-9]+$/,
numeric: /^\d+$/,
hexColor: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
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]?)$/
};
return patterns[type] ? patterns[type].test(input) : false;
}
}
module.exports.AppServerFactory = AppServerFactory;