Jens Krämer

Fail2ban With Devise-Based Rails Apps

 |  fail2ban, devise, warden, security, ruby, rails

Fail2ban is a daemon that scans your server log files for signs of suspicious activity, and takes action when a configured threshold is reached. A common use case is to block potentially malicious clients by IP after x failed login attempts to prevent brute force password guessing attacks.

The only thing you need to do in order to use this for protecting your Rails App is to log failed login attempts and provide a pattern to match the logfile against. Turns out that with Devise, the widely used authentication engine for Rails, that’s a surprisingly hard thing to do.

Look at Devise’s session controller and you see what I mean:

# POST /resource/sign_in
def create
  self.resource = warden.authenticate!(auth_options)
  set_flash_message(:notice, :signed_in) if is_flashing_format?
  sign_in(resource_name, resource)
  yield resource if block_given?
  respond_with resource, location: after_sign_in_path_for(resource)
end

So where’s the code path for the invalid credentials case? It’s simply not there! It all happens inside Warden, the General Rack Authentication Framework. Warden uses Ruby’s catch / throw construct to bail out of the standard control flow in case of authentication failures, so in case a user supplied a wrong password, we never return to the create method after calling warden.authenticate!.

Brute Force Approach - Catching the :warden

Now shouldn’t it be possible to build an around filter for that method, wrapping the original method in a catch block, and do the logging from there? Yes it is:

Devise::SessionsController.class_eval do
  around_filter :log_failures, only: :create

  def log_failures
    result = catch(:warden) do
      yield
    end

    # determine from result if a failure happened
    login_failed = false
    result ||= {}
    case result
    when Array
      if result.first == 401
        login_failed = true
      end
    when Hash
      login_failed = true
    end

    # if so, do the logging and re-throw :warden
    if login_failed
      Rails.error.log "Login failed for '#{params[:user][:email] rescue 'unknown'}' from #{request.remote_ip} at #{Time.now.utc.iso8601}"
      throw :warden
    end
  end
end

What’s interesting about this catch thing is that after the block you actually do not know if the :warden symbol was thrown or not. You just end up there and need to determine what’s up from the result of the block. That case construct comes straight from Warden’s Manager class where it’s used for the same purpose. Quite a lot of code to get our simple message written.

Better: Use Warden Callbacks

When I built the above I wasn’t aware of this, but Warden provides several Callbacks. Interesting for our use case is the before_failure hook:

Warden::Manager.before_failure do |env, opts|
  if opts[:action] == 'unauthenticated' and opts[:attempted_path] == '/users/sign_in'
    ip = env['action_dispatch.remote_ip'] || env['REMOTE_ADDR']
    user = env['action_dispatch.request.parameters']['user']['email'] rescue 'unknown'
    Rails.logger.error "Failed login for '#{user}' from #{ip} at #{Time.now.utc.iso8601}"
  end
end

I simply put that into config/initializers/devise.rb.

That’s much better, but I really dislike the if statement that is necessary to determine if there was an actual sign in failure, or if we just have an unauthorized request that’s redirected to the sign in page.

Unfortunately Warden does not differentiate between these two cases, they are simply both considered failures, and the callback is called no matter what. A callback that would only be triggered on failed sign in attempts would simplify things further, but from looking at Warden’s code it doesn’t seem to be very easy to add.

Finally, the Fail2ban Matcher

With our log message in place the rest is fairly easy. Put this into /etc/fail2ban/filter.d/your-rails-app.conf:

[INCLUDES]
before = common.conf

[Definition]
failregex = ^\s*(\[.+?\] )*Failed login for '.*' from <HOST> at $

The pattern takes into account any number of Rails’ log tags that might precede the message and stops just before the timestamp (which is consumed by Fail2ban).

Add a corresponding section to /etc/fail2ban/jail.local and you’re done:

[your-rails-app]
enabled = true
filter  = your-rails-app
port    = http,https
logpath = /path/to/your/production.log

Obviously this log-file based approach isn’t very useful for a clustered application - in this case you should try to achieve something similar at the load balancer level. You could for example set some HTTP header on the response and log that for Fail2ban, or use it directly to throttle / refuse offending clients, i.e. with Haproxy.

Comments

You can use Markdown here.

For the sake of spam checking any data you submit, including your IP address, will be transferred to the US based Akismet web service (akismet.com). If that's not acceptable for you, you can also reach me by other means.