1

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.

Community
  • 1
  • 1
Jacob
  • 1,826
  • 2
  • 22
  • 37
  • 1
    `Rack::Attack` is normally used for *throttling high-volume requests*; I would consider rules like "3 failed attempts = 4 sec delay" to be way too strict. Have a read through the [example configurations](https://github.com/kickstarter/rack-attack/wiki/Example-Configuration). I believe what you're really trying to achieve here would be better done with an [exponential backoff](https://github.com/kickstarter/rack-attack/wiki/Advanced-Configuration#exponential-backoff). – Tom Lord May 17 '17 at 08:33
  • Hey @TomLord, thanks very much for your feedback. I have the same feeling about that being too strict. Anyway, I did read the example configurations, but that exponential backoff leaves me with the same question. In this case, we still have a throttling over a resource (the login) but the system wouldn't penalize especially those failing, which I understand is the intent of the technique described in the guide. – Jacob May 17 '17 at 14:36
  • 1
    You could use `blacklist` instead of `throttle`, with pretty much the same code as in that example... I've used precisely this technique in a production application before (including tests to ensure it does work). If it helps, I could dig out this code and paste it below? – Tom Lord May 17 '17 at 14:57
  • If you can, I think it'd be a great example :) – Jacob May 17 '17 at 16:12

1 Answers1

4

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
Tom Lord
  • 22,829
  • 4
  • 43
  • 67
  • Thanks for posting this!! We're exploring this as an option for protecting our login post action. One concern we have is with NAT addresses, where lots of people share the same address. Our use case will have possibly 100's of people with the same address logging into the app in quick succession, then logins will tail off as the day progresses. Have you tested this configuration with such a use case? I've considered reversing the ban time logic so the smaller findtime windows will be banned for longer and the band time will decrease as the findtime window grows. – curtp Aug 08 '18 at 15:15
  • @curtp In my specific use case above, I didn't need to account for that - but I'm well aware of the issue, which is why I wrote *"You may wish to tweak those numbers as desired for your particular application"* :) – Tom Lord Aug 08 '18 at 17:17
  • @curtp Something you could consider, if possible, is to establish different rules for different IP address (ranges?) - i.e. relax the throttling for known IPs, or even during during different times of day. I know of other companies that do this in schools, for example - where the rate limiting is less relaxed from a school's IP during school hours. – Tom Lord Aug 08 '18 at 17:20
  • Yeah, we've been playing around with different numbers to see if we can find something that will work for most of our scenarios. Again, thanks for posting this solution! – curtp Aug 08 '18 at 17:44