Setting Up Rack Attack in Rails

Nov 3, 2021 • Ben Sochar

The Rack Attack gem is a must have for most projects. It can prevent DDoS attacks, password brute forcing & saves on hosting resources. It’s a good way to keep your logs free of script kid nonsense.

First I want to throttle requests posting to /login page so the app can’t be brute forced by randomly trying login/passworf combinations:

  throttle('logins/ip', limit: 1, period: 20.seconds) do |req|
    req.ip if req.path == '/login' && req.post?
  end

Same with the password resets.

  throttle('/password-resets/ip', limit: 10, period: 60.seconds) do |req|
    req.ip if req.path == '/password-reset/new' && req.get?
  end

Now let’s ban those stupid bots that endlessly curl Wordpress/Apache files:

  Rack::Attack.blocklist('fail2ban pentesters') do |req|
    # `filter` returns truthy value if request fails, or if it's from a previously banned IP
    # so the request is blocked
    Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 0, findtime: 1.minute, bantime: 2.day) do
      # The count for the IP is incremented if the return value is truthy
      CGI.unescape(req.query_string) =~ %r{/etc/passwd} ||
      req.path.include?('wp-admin') ||
      req.path.include?('wp-conten') ||
      req.path.include?('wp-includes') ||
      req.path.include?('wp-json') ||
      req.path.include?('wp-login') ||
      req.path.include?('php.ini') ||
      req.path.include?('PHP') ||
      req.path.include?('httpconfig')
    end
  end

Put it all together:

# frozen_string_literal: true

class Rack::Attack

  # By default, Rack::Attack uses `Rails.cache` to store requests information.
  # It's configurable as follows -
  # redis_client = Redis.new(url: ENV['REDIS_URL'] ||= 'redis://localhost:6379')
  # Rack::Attack.cache.prefix = 'rate-limit'
  # Rack::Attack.cache.store = Rack::Attack::StoreProxy::RedisStoreProxy.new(redis_client)

  class Request < ::Rack::Request

    # You many need to specify a method to fetch the correct remote IP address
    # if the web server is behind a load balancer.
    # Not sure we need this for Heroku
    def remote_ip
      @remote_ip ||= (env['HTTP_CF_CONNECTING_IP'] || env['action_dispatch.remote_ip'] || ip).to_s
    end

    def allowed_ip?
      allowed_ips = ['127.0.0.1', '::1']
      allowed_ips.include?(remote_ip)
    end
  end

  # Throttle all requests to root path by IP (40rpm/IP)
  throttle('req/ip', limit: 300, period: 5.minutes) do |req|
    req.ip #unless req.path.start_with?('/assets')
  end

  # Throttle POST requests to /login by IP address
  #
  # Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}"
  throttle('logins/ip', limit: 1, period: 20.seconds) do |req|
    req.ip if req.path == '/login' && req.post?
  end


  throttle('/password-resets/ip', limit: 10, period: 60.seconds) do |req|
    req.ip if req.path == '/password-reset/new' && req.get?
  end

  # Exponential backoff for all requests to root path
  #
  # Allows 240 requests in ~8 minutes
  #        480 requests in ~1 hour
  #        960 requests in ~8 hours (~2,880 requests/day)
  # (3..5).each do |level|
  #   throttle("req/ip/#{level}", limit: (30 * (2 ** level)), period: (0.9 * (8 ** level)).to_i.seconds) do |req|
  #     req.remote_ip if req.path == "/"
  #   end
  # end

  # Block suspicious requests for '/etc/password' or wordpress specific paths.
  # After 3 blocked requests in 10 minutes, block all requests from that IP for 5 minutes.
  Rack::Attack.blocklist('fail2ban pentesters') do |req|
    # `filter` returns truthy value if request fails, or if it's from a previously banned IP
    # so the request is blocked
    Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 0, findtime: 1.minute, bantime: 2.day) do
      # The count for the IP is incremented if the return value is truthy
      CGI.unescape(req.query_string) =~ %r{/etc/passwd} ||
      req.path.include?('wp-admin') ||
      req.path.include?('wp-conten') ||
      req.path.include?('wp-includes') ||
      req.path.include?('wp-json') ||
      req.path.include?('wp-login') ||
      req.path.include?('php.ini') ||
      req.path.include?('PHP') ||
      req.path.include?('httpconfig')
    end
  end

  # Split on a comma with 0 or more spaces after it.
  # E.g. ENV['HEROKU_VARIABLE'] = "foo.com, bar.com"
  # spammers = ["foo.com", "bar.com"]
  # spammers = ENV['HEROKU_VARIABLE'].split(/,\s*/)
  ip_arr = ENV['BLOCKED_IPS'] || ''
  blocked_ips = ip_arr.split(/,\s*/)

  # # Turn spammers array into a regexp
  # spammer_regexp = Regexp.union(spammers) # /foo\.com|bar\.com/
  blocklist('block ips') do |req|
    blocked_ips.include?(req.ip)
  end

  # Do not throttle for allowed IPs
  safelist('allow from localhost') do |req|
    req.allowed_ip?
  end
end

Rails & Ruby