Preventing rapid-fire login attempts with Rack::Attack

2.2k Views Asked by At

We have been reading the Definitive guide to form based website authentication with the intention of preventing rapid-fire login attempts.

One example of this could be:

  • 1 failed attempt = no delay
  • 2 failed attempts = 2 sec delay
  • 3 failed attempts = 4 sec delay
  • etc

Other methods appear in the guide, but they all require a storage capable of recording previous failed attempts.

Blocklisting is discussed in one of the posts in this issue (appears under the old name blacklisting that was changed in the documentation to blocklisting) as a possible solution.

As per Rack::Attack specifically, one naive example of implementation could be:

Where the login fails:

StorageMechanism.increment("bad-login/#{req.ip")

In the rack-attack.rb:

Rack::Attack.blacklist('bad-logins') { |req|
    StorageMechanism.get("bad-login/#{req.ip}")
}

There are two parts here, returning the response if it is blocklisted and check if a previous failed attempt happened (StorageMechanism).

The first part, returning the response, can be handled automatically by the gem. However, I don't see so clear the second part, at least with the de-facto choice for cache backend for the gem and Rails world, Redis.

As far as I know, the expired keys in Redis are automatically removed. That would make impossible to access the information (even if expired), set a new value for the counter and increment accordingly the timeout for the refractory period.

Is there any way to achieve this with Redis and Rack::Attack?

I was thinking that maybe the 'StorageMechanism' has to remain absolutely agnostic in this case and know nothing about Rack::Attack and its storage choices.

1

There are 1 best solutions below

4
On BEST ANSWER

Sorry for the delay in getting back to you; it took me a while to dig out my old code relating to this.

As discussed in the comments above, here is a solution using a blacklist, with a findtime

# config/initilizers/rack-attack.rb
class Rack::Attack
  (1..6).each do |level| 
    blocklist("allow2ban login scrapers - level #{level}") do |req| 
      Allow2Ban.filter( 
        req.ip, 
        maxretry: (20 * level), 
        findtime: (8**level).seconds, 
        bantime: (8**level).seconds 
      ) do 
        req.path == '/users/sign_in' && req.post? 
      end 
    end 
  end
end

You may wish to tweak those numbers as desired for your particular application; the figures above are only what I decided as 'sensible' for my particular application - they do not come from any official standard.

One issue with using the above that when developing/testing (e.g. your rspec test suite) the application, you can easily hit the above limits and inadvertently throttle yourself. This can be avoided by adding the following config to the initializer:

safelist('allow from localhost') do |req|
  '127.0.0.1' == req.ip || '::1' == req.ip
end

The most common brute-force login attack is a brute-force password attack where an attacker simply tries a large number of emails and passwords to see if any credentials match.

You should mitigate this in the application by use of an account LOCK after a few failed login attempts. (For example, if using devise then there is a built-in Lockable module that you can make use of.)

However, this account-locking approach opens a new attack vector: An attacker can spam the system with login attempts, using valid emails and incorrect passwords, to continuously re-lock all accounts!

This configuration helps mitigate that attack vector, by exponentially limiting the number of sign-in attempts from a given IP.

I also added the following "catch-all" request throttle:

throttle('req/ip', limit: 300, period: 5.minutes, &:ip)

This is primarily to throttle malicious/poorly configured scrapers; to prevent them from hogging all of the app server's CPU.

Note: If you're serving assets through rack, those requests may be counted by rack-attack and this throttle may be activated too quickly. If so, enable the condition to exclude them from tracking.


I also wrote an integration test to ensure that my Rack::Attack configuration was doing its job. There were a few challenges in making this test work, so I'll let the code+comments speak for itself:

class Rack::AttackTest < ActionDispatch::IntegrationTest 
  setup do 
    # Prevent subtle timing issues (==> intermittant test failures) 
    # when the HTTP requests span across multiple seconds 
    # by FREEZING TIME(!!) for the duration of the test 
    travel_to(Time.now) 

    @removed_safelist = Rack::Attack.safelists.delete('allow from localhost') 
    # Clear the Rack::Attack cache, to prevent test failure when 
    # running multiple times in quick succession. 
    # 
    # First, un-ban localhost, in case it is already banned after a previous test: 
    (1..6).each do |level| 
      Rack::Attack::Allow2Ban.reset('127.0.0.1', findtime: (8**level).seconds) 
    end 
    # Then, clear the 300-request rate limiter cache: 
    Rack::Attack.cache.delete("#{Time.now.to_i / 5.minutes}:req/ip:127.0.0.1") 
  end 

  teardown do 
    travel_back # Un-freeze time 
    Rack::Attack.safelists['allow from localhost'] = @removed_safelist 
  end 

  test 'should block access on 20th successive /users/sign_in attempt' do 
    19.times do |i| 
      post user_session_url 
      assert_response :success, "was not even allowed to TRY to login on attempt number #{i + 1}" 
    end 

    # For DOS protection: Don't even let the user TRY to login; they're going way too fast. 
    # Rack::Attack returns 403 for blocklists by default, but this can be reconfigured: 
    # https://github.com/kickstarter/rack-attack/blob/master/README.md#responses 
    post user_session_url 
    assert_response :forbidden, 'login access should be blocked upon 20 successive attempts' 
  end 
end