Safer web: why does brute-force protection of login endpoints so important?
Roman Voloboev
Posted on February 2, 2019
We all know why. Because it saves private data and money. But that is not all. The most important, that it makes the internet safer place over all, so users can get better experience and be happier with web services.
Some time ago I've created a Node.js package rate-limiter-flexible, which provides tools against DoS and brute-force attacks with many features. I dived into this topic and discovered, that some javascript open-source projects don't care much about security. I am not sure about projects on other languages, but guess it is the same. There are many e-commerce projects, which don't care much too.
I've recently posted an article about brute-force protection with analysis and examples. You can read full version here.
Here is one example, first of all as a reminder, that we (developers, PMs, CEOs, etc) should take care of it. No time to write extra code? No worries, it is easy.
The main idea of protection is risk minimisation. Login endpoint limits number of allowed requests and block extra requests.
We should create 2 different limiters:
- The first counts number of consecutive failed attempts and allows maximum 10 by Username+IP pair.
- The second blocks IP for 1 day on 100 failed attempts per day.
const http = require('http');
const express = require('express');
const redis = require('redis');
const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = redis.createClient({
enable_offline_queue: false,
});
const maxWrongAttemptsByIPperDay = 100;
const maxConsecutiveFailsByUsernameAndIP = 10;
const limiterSlowBruteByIP = new RateLimiterRedis({
redis: redisClient,
keyPrefix: 'login_fail_ip_per_day',
points: maxWrongAttemptsByIPperDay,
duration: 60 * 60 * 24,
blockDuration: 60 * 60 * 24, // Block for 1 day, if 100 wrong attempts per day
});
const limiterConsecutiveFailsByUsernameAndIP = new RateLimiterRedis({
redis: redisClient,
keyPrefix: 'login_fail_consecutive_username_and_ip',
points: maxConsecutiveFailsByUsernameAndIP,
duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail
blockDuration: 60 * 60 * 24 * 365 * 20, // Block for infinity after consecutive fails
});
const getUsernameIPkey = (username, ip) => `${username}_${ip}`;
async function loginRoute(req, res) {
const ipAddr = req.connection.remoteAddress;
const usernameIPkey = getUsernameIPkey(req.body.email, ipAddr);
const [resUsernameAndIP, resSlowByIP] = await Promise.all([
limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey),
limiterSlowBruteByIP.get(ipAddr),
]);
let retrySecs = 0;
// Check if IP or Username + IP is already blocked
if (resSlowByIP !== null && resSlowByIP.remainingPoints <= 0) {
retrySecs = Math.round(resSlowByIP.msBeforeNext / 1000) || 1;
} else if (resUsernameAndIP !== null && resUsernameAndIP.remainingPoints <= 0) {
retrySecs = Math.round(resUsernameAndIP.msBeforeNext / 1000) || 1;
}
if (retrySecs > 0) {
res.set('Retry-After', String(retrySecs));
res.status(429).send('Too Many Requests');
} else {
const user = authorise(req.body.email, req.body.password);
if (!user.isLoggedIn) {
// Consume 1 point from limiters on wrong attempt and block if limits reached
try {
const promises = [limiterSlowBruteByIP.consume(ipAddr)];
if (user.exists) {
// Count failed attempts by Username + IP only for registered users
promises.push(limiterConsecutiveFailsByUsernameAndIP.consume(usernameIPkey));
}
await promises;
res.status(400).end('email or password is wrong');
} catch (rlRejected) {
if (rlRejected instanceof Error) {
throw rlRejected;
} else {
res.set('Retry-After', String(Math.round(rlRejected.msBeforeNext / 1000)) || 1);
res.status(429).send('Too Many Requests');
}
}
}
if (user.isLoggedIn) {
if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > 0) {
// Reset on successful authorisation
await limiterConsecutiveFailsByUsernameAndIP.delete(usernameIPkey);
}
res.end('authorized');
}
}
}
const app = express();
app.post('/login', async (req, res) => {
try {
await loginRoute(req, res);
} catch (err) {
res.status(500).end();
}
});
Implementation of unblocking is up to you, there is suitable delete(key)
method.
More examples in this article and in official docs
Posted on February 2, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 2, 2019