- Updated all component headers and documentation
- Changed navbar and footer branding
- Updated homepage hero badge
- Modified page title in index.html
- Simplified footer text to 'Built with ❤️'
- Consistent V2 capitalization across all references
348 lines
11 KiB
JavaScript
348 lines
11 KiB
JavaScript
const {
|
|
LIMITER_TYPES,
|
|
ERR_UNKNOWN_LIMITER_TYPE_MESSAGE,
|
|
} = require('./constants');
|
|
const crypto = require('crypto');
|
|
const {
|
|
RateLimiterMemory,
|
|
RateLimiterCluster,
|
|
RateLimiterMemcache,
|
|
RateLimiterMongo,
|
|
RateLimiterMySQL,
|
|
RateLimiterPostgres,
|
|
RateLimiterRedis,
|
|
} = require('../index');
|
|
|
|
function getDelayMs(count, delays, maxWait) {
|
|
let msDelay = maxWait;
|
|
const delayIndex = count - 1;
|
|
if (delayIndex >= 0 && delayIndex < delays.length) {
|
|
msDelay = delays[delayIndex];
|
|
}
|
|
|
|
return msDelay;
|
|
}
|
|
|
|
const ExpressBruteFlexible = function (limiterType, options) {
|
|
ExpressBruteFlexible.instanceCount++;
|
|
this.name = `brute${ExpressBruteFlexible.instanceCount}`;
|
|
|
|
this.options = Object.assign({}, ExpressBruteFlexible.defaults, options);
|
|
if (this.options.minWait < 1) {
|
|
this.options.minWait = 1;
|
|
}
|
|
|
|
const validLimiterTypes = Object.keys(ExpressBruteFlexible.LIMITER_TYPES).map(k => ExpressBruteFlexible.LIMITER_TYPES[k]);
|
|
if (!validLimiterTypes.includes(limiterType)) {
|
|
throw new Error(ERR_UNKNOWN_LIMITER_TYPE_MESSAGE);
|
|
}
|
|
this.limiterType = limiterType;
|
|
|
|
this.delays = [this.options.minWait];
|
|
while (this.delays[this.delays.length - 1] < this.options.maxWait) {
|
|
const nextNum = this.delays[this.delays.length - 1] + (this.delays.length > 1 ? this.delays[this.delays.length - 2] : 0);
|
|
this.delays.push(nextNum);
|
|
}
|
|
this.delays[this.delays.length - 1] = this.options.maxWait;
|
|
|
|
// set default lifetime
|
|
if (typeof this.options.lifetime === 'undefined') {
|
|
this.options.lifetime = Math.ceil((this.options.maxWait / 1000) * (this.delays.length + this.options.freeRetries));
|
|
}
|
|
|
|
this.prevent = this.getMiddleware({
|
|
prefix: this.options.prefix,
|
|
});
|
|
};
|
|
|
|
ExpressBruteFlexible.prototype.getMiddleware = function (options) {
|
|
const opts = Object.assign({}, options);
|
|
const commonKeyPrefix = opts.prefix || '';
|
|
const freeLimiterOptions = {
|
|
storeClient: this.options.storeClient,
|
|
storeType: this.options.storeType,
|
|
keyPrefix: `${commonKeyPrefix}free`,
|
|
dbName: this.options.dbName,
|
|
tableName: this.options.tableName,
|
|
points: this.options.freeRetries > 0 ? this.options.freeRetries - 1 : 0,
|
|
duration: this.options.lifetime,
|
|
};
|
|
|
|
const blockLimiterOptions = {
|
|
storeClient: this.options.storeClient,
|
|
storeType: this.options.storeType,
|
|
keyPrefix: `${commonKeyPrefix}block`,
|
|
dbName: this.options.dbName,
|
|
tableName: this.options.tableName,
|
|
points: 1,
|
|
duration: Math.min(this.options.lifetime, Math.ceil((this.options.maxWait / 1000))),
|
|
};
|
|
|
|
const counterLimiterOptions = {
|
|
storeClient: this.options.storeClient,
|
|
storeType: this.options.storeType,
|
|
keyPrefix: `${commonKeyPrefix}counter`,
|
|
dbName: this.options.dbName,
|
|
tableName: this.options.tableName,
|
|
points: 1,
|
|
duration: this.options.lifetime,
|
|
};
|
|
|
|
switch (this.limiterType) {
|
|
case 'memory':
|
|
this.freeLimiter = new RateLimiterMemory(freeLimiterOptions);
|
|
this.blockLimiter = new RateLimiterMemory(blockLimiterOptions);
|
|
this.counterLimiter = new RateLimiterMemory(counterLimiterOptions);
|
|
break;
|
|
case 'cluster':
|
|
this.freeLimiter = new RateLimiterCluster(freeLimiterOptions);
|
|
this.blockLimiter = new RateLimiterCluster(blockLimiterOptions);
|
|
this.counterLimiter = new RateLimiterCluster(counterLimiterOptions);
|
|
break;
|
|
case 'memcache':
|
|
this.freeLimiter = new RateLimiterMemcache(freeLimiterOptions);
|
|
this.blockLimiter = new RateLimiterMemcache(blockLimiterOptions);
|
|
this.counterLimiter = new RateLimiterMemcache(counterLimiterOptions);
|
|
break;
|
|
case 'mongo':
|
|
this.freeLimiter = new RateLimiterMongo(freeLimiterOptions);
|
|
this.blockLimiter = new RateLimiterMongo(blockLimiterOptions);
|
|
this.counterLimiter = new RateLimiterMongo(counterLimiterOptions);
|
|
break;
|
|
case 'mysql':
|
|
this.freeLimiter = new RateLimiterMySQL(freeLimiterOptions);
|
|
this.blockLimiter = new RateLimiterMySQL(blockLimiterOptions);
|
|
this.counterLimiter = new RateLimiterMySQL(counterLimiterOptions);
|
|
break;
|
|
case 'postgres':
|
|
this.freeLimiter = new RateLimiterPostgres(freeLimiterOptions);
|
|
this.blockLimiter = new RateLimiterPostgres(blockLimiterOptions);
|
|
this.counterLimiter = new RateLimiterPostgres(counterLimiterOptions);
|
|
break;
|
|
case 'redis':
|
|
this.freeLimiter = new RateLimiterRedis(freeLimiterOptions);
|
|
this.blockLimiter = new RateLimiterRedis(blockLimiterOptions);
|
|
this.counterLimiter = new RateLimiterRedis(counterLimiterOptions);
|
|
break;
|
|
default:
|
|
throw new Error(ERR_UNKNOWN_LIMITER_TYPE_MESSAGE);
|
|
}
|
|
|
|
let keyFunc = opts.key;
|
|
if (typeof keyFunc !== 'function') {
|
|
keyFunc = function (req, res, next) {
|
|
next(opts.key);
|
|
};
|
|
}
|
|
|
|
const getFailCallback = (() => (typeof opts.failCallback === 'undefined' ? this.options.failCallback : opts.failCallback));
|
|
|
|
return (req, res, next) => {
|
|
const cannotIncrementErrorObjectBase = {
|
|
req,
|
|
res,
|
|
next,
|
|
message: 'Cannot increment request count',
|
|
};
|
|
|
|
keyFunc(req, res, (key) => {
|
|
if (!opts.ignoreIP) {
|
|
key = ExpressBruteFlexible._getKey([req.ip, this.name, key]);
|
|
} else {
|
|
key = ExpressBruteFlexible._getKey([this.name, key]);
|
|
}
|
|
|
|
// attach a simpler "reset" function to req.brute.reset
|
|
if (this.options.attachResetToRequest) {
|
|
let reset = ((callback) => {
|
|
Promise.all([
|
|
this.freeLimiter.delete(key),
|
|
this.blockLimiter.delete(key),
|
|
this.counterLimiter.delete(key),
|
|
]).then(() => {
|
|
if (typeof callback === 'function') {
|
|
process.nextTick(() => {
|
|
callback();
|
|
});
|
|
}
|
|
}).catch((err) => {
|
|
if (typeof callback === 'function') {
|
|
process.nextTick(() => {
|
|
callback(err);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
if (req.brute && req.brute.reset) {
|
|
// wrap existing reset if one exists
|
|
const oldReset = req.brute.reset;
|
|
const newReset = reset;
|
|
reset = function (callback) {
|
|
oldReset(() => {
|
|
newReset(callback);
|
|
});
|
|
};
|
|
}
|
|
req.brute = {
|
|
reset,
|
|
};
|
|
}
|
|
|
|
this.freeLimiter.consume(key)
|
|
.then(() => {
|
|
if (typeof next === 'function') {
|
|
next();
|
|
}
|
|
})
|
|
.catch(() => {
|
|
Promise.all([
|
|
this.blockLimiter.get(key),
|
|
this.counterLimiter.get(key),
|
|
])
|
|
.then((allRes) => {
|
|
const [blockRes, counterRes] = allRes;
|
|
|
|
if (blockRes === null) {
|
|
const msDelay = getDelayMs(
|
|
counterRes ? counterRes.consumedPoints + 1 : 1,
|
|
this.delays,
|
|
// eslint-disable-next-line
|
|
this.options.maxWait
|
|
);
|
|
|
|
this.blockLimiter.penalty(key, 1, { customDuration: Math.ceil(msDelay / 1000) })
|
|
.then((blockPenaltyRes) => {
|
|
if (blockPenaltyRes.consumedPoints === 1) {
|
|
this.counterLimiter.penalty(key)
|
|
.then(() => {
|
|
if (typeof next === 'function') {
|
|
next();
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
this.options.handleStoreError(Object.assign({}, cannotIncrementErrorObjectBase, { parent: err }));
|
|
});
|
|
} else {
|
|
const nextValidDate = new Date(Date.now() + blockPenaltyRes.msBeforeNext);
|
|
|
|
const failCallback = getFailCallback();
|
|
if (typeof failCallback === 'function') {
|
|
failCallback(req, res, next, nextValidDate);
|
|
}
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
this.options.handleStoreError(Object.assign({}, cannotIncrementErrorObjectBase, { parent: err }));
|
|
});
|
|
} else {
|
|
const nextValidDate = new Date(Date.now() + blockRes.msBeforeNext);
|
|
|
|
const failCallback = getFailCallback();
|
|
if (typeof failCallback === 'function') {
|
|
failCallback(req, res, next, nextValidDate);
|
|
}
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
this.options.handleStoreError(Object.assign({}, cannotIncrementErrorObjectBase, { parent: err }));
|
|
});
|
|
});
|
|
});
|
|
};
|
|
};
|
|
|
|
ExpressBruteFlexible.prototype.reset = function (ip, key, callback) {
|
|
let keyArgs = [];
|
|
if (ip) {
|
|
keyArgs.push(ip)
|
|
}
|
|
keyArgs.push(this.name);
|
|
keyArgs.push(key);
|
|
const ebKey = ExpressBruteFlexible._getKey(keyArgs);
|
|
|
|
Promise.all([
|
|
this.freeLimiter.delete(ebKey),
|
|
this.blockLimiter.delete(ebKey),
|
|
this.counterLimiter.delete(ebKey),
|
|
]).then(() => {
|
|
if (typeof callback === 'function') {
|
|
process.nextTick(() => {
|
|
callback();
|
|
});
|
|
}
|
|
}).catch((err) => {
|
|
this.options.handleStoreError({
|
|
message: 'Cannot reset request count',
|
|
parent: err,
|
|
key,
|
|
ip,
|
|
});
|
|
});
|
|
};
|
|
|
|
ExpressBruteFlexible._getKey = function (arr) {
|
|
let key = '';
|
|
|
|
arr.forEach((part) => {
|
|
if (part) {
|
|
key += crypto.createHash('sha256').update(part).digest('base64');
|
|
}
|
|
});
|
|
|
|
return crypto.createHash('sha256').update(key).digest('base64');
|
|
};
|
|
|
|
const setRetryAfter = function (res, nextValidRequestDate) {
|
|
const secondUntilNextRequest = Math.ceil((nextValidRequestDate.getTime() - Date.now()) / 1000);
|
|
res.header('Retry-After', secondUntilNextRequest);
|
|
};
|
|
ExpressBruteFlexible.FailTooManyRequests = function (req, res, next, nextValidRequestDate) {
|
|
setRetryAfter(res, nextValidRequestDate);
|
|
res.status(429);
|
|
res.send({
|
|
error: {
|
|
text: 'Too many requests in this time frame.',
|
|
nextValidRequestDate,
|
|
},
|
|
});
|
|
};
|
|
ExpressBruteFlexible.FailForbidden = function (req, res, next, nextValidRequestDate) {
|
|
setRetryAfter(res, nextValidRequestDate);
|
|
res.status(403);
|
|
res.send({
|
|
error: {
|
|
text: 'Too many requests in this time frame.',
|
|
nextValidRequestDate,
|
|
},
|
|
});
|
|
};
|
|
ExpressBruteFlexible.FailMark = function (req, res, next, nextValidRequestDate) {
|
|
res.status(429);
|
|
setRetryAfter(res, nextValidRequestDate);
|
|
res.nextValidRequestDate = nextValidRequestDate;
|
|
next();
|
|
};
|
|
|
|
ExpressBruteFlexible.defaults = {
|
|
freeRetries: 2,
|
|
attachResetToRequest: true,
|
|
minWait: 500,
|
|
maxWait: 1000 * 60 * 15,
|
|
failCallback: ExpressBruteFlexible.FailTooManyRequests,
|
|
handleStoreError(err) {
|
|
// eslint-disable-next-line
|
|
throw {
|
|
message: err.message,
|
|
parent: err.parent,
|
|
};
|
|
},
|
|
};
|
|
|
|
ExpressBruteFlexible.LIMITER_TYPES = LIMITER_TYPES;
|
|
|
|
ExpressBruteFlexible.instanceCount = 0;
|
|
|
|
|
|
module.exports = ExpressBruteFlexible;
|