Fail2ban With Devise-Based Rails Apps
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.