loading...

Safer web: why does brute-force protection of login endpoints so important?

animir profile image Roman Voloboev ・3 min read

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:

  1. The first counts number of consecutive failed attempts and allows maximum 10 by Username+IP pair.
  2. 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 by:

animir profile

Roman Voloboev

@animir

Full Stack developer and open-source lover

Discussion

markdown guide
 

Hi Roman,

I was searching for express-brute and saw this security issue about concurrency github.com/AdamPflug/express-brute...

Is your package different?

Thanks.

 

Hi lepinekong,

rate-limiter-flexible built on top of atomic increments (express-brute doesn't).
You can find a lot of brute force protection examples on Wiki.

There is also ExpressBruteFlexible. It has exactly the same set of functions as express-brute, but built on top of atomic increments.

Hope, this helps.