Skip to content

Rack middleware for blocking & throttling

License

Notifications You must be signed in to change notification settings

tablecheck/rack-attack

 
 

Repository files navigation

⚠️ You are viewing the development's branch version of README which might contain documentation for unreleased features. For the README consistent with the latest released version see https://github.com/rack/rack-attack/blob/6-stable/README.md.

Rack::Attack

Rack middleware for blocking & throttling abusive requests

Protect your Rails and Rack apps from bad clients. Rack::Attack lets you easily decide when to allow, block and throttle based on properties of the request.

See the Backing & Hacking blog post introducing Rack::Attack.

Gem Version build Code Climate Join the chat at https://gitter.im/rack-attack/rack-attack

Table of contents

Getting started

Installing

Add this line to your application's Gemfile:

# In your Gemfile

gem 'rack-attack'

And then execute:

$ bundle

Or install it yourself as:

$ gem install rack-attack

Plugging into the application

Then tell your ruby web application to use rack-attack as a middleware.

a) For rails applications it is used by default.

You can disable it permanently (like for specific environment) or temporarily (can be useful for specific test cases) by writing:

Rack::Attack.enabled = false

b) For rack applications:

# In config.ru

require "rack/attack"
use Rack::Attack

IMPORTANT: By default, rack-attack won't perform any blocking or throttling, until you specifically tell it what to protect against by configuring some rules.

Usage

Tip: If you just want to get going asap, then you can take our example configuration and tailor it to your needs, or check out the advanced configuration examples.

Define rules by calling Rack::Attack public methods, in any file that runs when your application is being initialized. For rails applications this means creating a new file named config/initializers/rack_attack.rb and writing your rules there.

Safelisting

Safelists have the most precedence, so any request matching a safelist would be allowed despite matching any number of blocklists or throttles.

safelist_ip(ip_address_string)

E.g.

# config/initializers/rack_attack.rb (for rails app)

Rack::Attack.safelist_ip("5.6.7.8")

safelist_ip(ip_subnet_string)

E.g.

# config/initializers/rack_attack.rb (for rails app)

Rack::Attack.safelist_ip("5.6.7.0/24")

safelist(name, &block)

Name your custom safelist and make your ruby-block argument return a truthy value if you want the request to be allowed, and falsy otherwise.

The request object is a Rack::Request.

E.g.

# config/initializers/rack_attack.rb (for rails apps)

# Provided that trusted users use an HTTP request header named APIKey
Rack::Attack.safelist("mark any authenticated access safe") do |request|
  # Requests are allowed if the return value is truthy
  request.env["HTTP_APIKEY"] == "secret-string"
end

# Always allow requests from localhost
# (blocklist & throttles are skipped)
Rack::Attack.safelist('allow from localhost') do |req|
  # Requests are allowed if the return value is truthy
  '127.0.0.1' == req.ip || '::1' == req.ip
end

Blocking

blocklist_ip(ip_address_string)

E.g.

# config/initializers/rack_attack.rb (for rails apps)

Rack::Attack.blocklist_ip("1.2.3.4")

blocklist_ip(ip_subnet_string)

E.g.

# config/initializers/rack_attack.rb (for rails apps)

Rack::Attack.blocklist_ip("1.2.0.0/16")

blocklist(name, &block)

Name your custom blocklist and make your ruby-block argument return a truthy value if you want the request to be blocked, and falsy otherwise.

The request object is a Rack::Request.

E.g.

# config/initializers/rack_attack.rb (for rails apps)

Rack::Attack.blocklist("block all access to admin") do |request|
  # Requests are blocked if the return value is truthy
  request.path.start_with?("/admin")
end

Rack::Attack.blocklist('block bad UA logins') do |req|
  req.path == '/login' && req.post? && req.user_agent == 'BadUA'
end

Fail2Ban

Fail2Ban.filter can be used within a blocklist to block all requests from misbehaving clients. This pattern is inspired by fail2ban. See the fail2ban documentation for more details on how the parameters work. For multiple filters, be sure to put each filter in a separate blocklist and use a unique discriminator for each fail2ban filter.

Fail2ban state is stored in a configurable cache (which defaults to Rails.cache if present).

# 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: 3, findtime: 10.minutes, bantime: 5.minutes) 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?('/etc/passwd') ||
    req.path.include?('wp-admin') ||
    req.path.include?('wp-login')

  end
end

Note that Fail2Ban filters are not automatically scoped to the blocklist, so when using multiple filters in an application the scoping must be added to the discriminator e.g. "pentest:#{req.ip}".

Allow2Ban

Allow2Ban.filter works the same way as the Fail2Ban.filter except that it allows requests from misbehaving clients until such time as they reach maxretry at which they are cut off as per normal.

Allow2ban state is stored in a configurable cache (which defaults to Rails.cache if present).

# Lockout IP addresses that are hammering your login page.
# After 20 requests in 1 minute, block all requests from that IP for 1 hour.
Rack::Attack.blocklist('allow2ban login scrapers') do |req|
  # `filter` returns false value if request is to your login page (but still
  # increments the count) so request below the limit are not blocked until
  # they hit the limit.  At that point, filter will return true and block.
  Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 20, findtime: 1.minute, bantime: 1.hour) do
    # The count for the IP is incremented if the return value is truthy.
    req.path == '/login' and req.post?
  end
end

Throttling

Throttle state is stored in a configurable cache (which defaults to Rails.cache if present).

throttle(name, options, &block)

Name your custom throttle, provide limit and period as options, and make your ruby-block argument return the discriminator. This discriminator is how you tell rack-attack whether you're limiting per IP address, per user email or any other.

The request object is a Rack::Request.

E.g.

# config/initializers/rack_attack.rb (for rails apps)

Rack::Attack.throttle("requests by ip", limit: 5, period: 2) do |request|
  request.ip
end

# Throttle login attempts for a given email parameter to 6 reqs/minute
# Return the *normalized* email as a discriminator on POST /login requests
Rack::Attack.throttle('limit logins per email', limit: 6, period: 60) do |req|
  if req.path == '/login' && req.post?
    # Normalize the email, using the same logic as your authentication process, to
    # protect against rate limit bypasses.
    req.params['email'].to_s.downcase.gsub(/\s+/, "")
  end
end

# You can also set a limit and period using a proc. For instance, after
# Rack::Auth::Basic has authenticated the user:
limit_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 100 : 1 }
period_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 1 : 60 }

Rack::Attack.throttle('request per ip', limit: limit_proc, period: period_proc) do |request|
  request.ip
end

Tracks

# Track requests from a special user agent.
Rack::Attack.track("special_agent") do |req|
  req.user_agent == "SpecialAgent"
end

# Supports optional limit and period, triggers the notification only when the limit is reached.
Rack::Attack.track("special_agent", limit: 6, period: 60) do |req|
  req.user_agent == "SpecialAgent"
end

# Track it using ActiveSupport::Notification
ActiveSupport::Notifications.subscribe("track.rack_attack") do |name, start, finish, request_id, payload|
  req = payload[:request]
  if req.env['rack.attack.matched'] == "special_agent"
    Rails.logger.info "special_agent: #{req.path}"
    STATSD.increment("special_agent")
  end
end

Cache store configuration

Throttle, allow2ban and fail2ban state is stored in a configurable cache (which defaults to Rails.cache if present), presumably backed by memcached or redis (at least gem v3.0.0).

Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new # defaults to Rails.cache

Note that Rack::Attack.cache is only used for throttling, allow2ban and fail2ban filtering; not blocklisting and safelisting. Your cache store must implement increment and write like ActiveSupport::Cache::Store.

Customizing responses

Customize the response of blocklisted and throttled requests using an object that adheres to the Rack app interface.

Rack::Attack.blocklisted_responder = lambda do |request|
  # Using 503 because it may make attacker think that they have successfully
  # DOSed the site. Rack::Attack returns 403 for blocklists by default
  [ 503, {}, ['Blocked']]
end

Rack::Attack.throttled_responder = lambda do |request|
  # NB: you have access to the name and other data about the matched throttle
  #  request.env['rack.attack.matched'],
  #  request.env['rack.attack.match_type'],
  #  request.env['rack.attack.match_data'],
  #  request.env['rack.attack.match_discriminator']

  # Using 503 because it may make attacker think that they have successfully
  # DOSed the site. Rack::Attack returns 429 for throttling by default
  [ 503, {}, ["Server Error\n"]]
end

RateLimit headers for well-behaved clients

While Rack::Attack's primary focus is minimizing harm from abusive clients, it can also be used to return rate limit data that's helpful for well-behaved clients.

If you want to return to user how many seconds to wait until they can start sending requests again, this can be done through enabling Retry-After header:

Rack::Attack.throttled_response_retry_after_header = true

Here's an example response that includes conventional RateLimit-* headers:

Rack::Attack.throttled_response = lambda do |env|
  match_data = env['rack.attack.match_data']
  now = match_data[:epoch_time]

  headers = {
    'RateLimit-Limit' => match_data[:limit].to_s,
    'RateLimit-Remaining' => '0',
    'RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s
  }

  [ 429, headers, ["Throttled\n"]]
end

For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:

request.env['rack.attack.throttle_data'][name] # => { discriminator: d, count: n, period: p, limit: l, epoch_time: t }

Logging & Instrumentation

Rack::Attack uses the ActiveSupport::Notifications API if available.

You can subscribe to rack_attack events and log it, graph it, etc.

To get notified about specific type of events, subscribe to the event name followed by the rack_attack namespace. E.g. for throttles use:

ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, request_id, payload|
  # request object available in payload[:request]

  # Your code here
end

If you want to subscribe to every rack_attack event, use:

ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, request_id, payload|
  # request object available in payload[:request]

  # Your code here
end

Fault Tolerance & Error Handling

Rack::Attack has a mission-critical dependency on your cache store. If the cache system experiences an outage, it may cause severe latency within Rack::Attack and lead to an overall application outage.

This section explains how to configure your application and handle errors in order to mitigate issues.

Built-in error handling

By default, Rack::Attack "does the right thing" when errors occur:

  • If the error is a Redis or Dalli cache error, Rack::Attack allows the error and allow the request.
  • Otherwise, Rack::Attack re-raises the error. The request will fail.

All errors will trigger a failure cooldown (see below), regardless of whether they are allowed or raised.

Expose Rails cache errors to Rack::Attack

If you are using Rack::Attack with Rails cache, by default, Rails cache will suppress any such errors, and Rack::Attack will not be able to handle them properly as per above. This can be dangerous: if your cache is timing out due to high request volume, for example, Rack::Attack will continue to blindly send requests to your cache and worsen the problem.

When using Rails cache with :redis_cache_store, you'll need to expose errors to Rack::Attack with a custom error handler as follows:

# in your Rails config
config.cache_store = :redis_cache_store,
                     { # ...
                       error_handler: -> (method:, returning:, exception:) do
                         raise exception if Rack::Attack.calling?
                       end
                     }

Rails :mem_cache_store and :dalli_store suppress all Dalli errors. The recommended workaround is to set a Rack::Attack-specific cache configuration.

Configure cache timeout

In your application config, it is recommended to set your cache timeout to 0.1 seconds or lower. Please refer to the Rails Guide.

# Set 100 millisecond timeout on Redis
config.cache_store = :redis_cache_store,
                     { # ...
                       connect_timeout: 0.1,
                       read_timeout: 0.1,
                       write_timeout: 0.1
                     }

To use different timeout values specific to Rack::Attack, you may set a Rack::Attack-specific cache configuration.

Failure cooldown

When any error occurs, Rack::Attack becomes disabled for a 60 seconds "cooldown" period. This prevents a cache outage from adding timeout latency on each Rack::Attack request. All errors trigger the failure cooldown, regardless of whether they are allowed or handled. You can configure the cooldown period as follows:

# in initializers/rack_attack.rb

# Disable Rack::Attack for 5 minutes if any cache failure occurs
Rack::Attack.failure_cooldown = 300

# Do not use failure cooldown
Rack::Attack.failure_cooldown = nil

Custom error handling

For most use cases, it is not necessary to re-configure Rack::Attack's default error handling. However, there are several ways you may do so.

First, you may specify the list of errors to allow as an array of Class and/or String values.

# in initializers/rack_attack.rb
Rack::Attack.allowed_errors += [MyErrorClass, 'MyOtherErrorClass']

Alternatively, you may define a custom error handler as a Proc. The error handler will receive all errors, regardless of whether they are on the allow list. Your handler should return either :allow, :block, or :throttle, or else re-raise the error; other returned values will allow the request.

# Set a custom error handler which blocks allowed errors
# and raises all others
Rack::Attack.error_handler = -> (error) do
  if Rack::Attack.allow_error?(error)
    Rails.logger.warn("Blocking error: #{error}")
    :block
  else
    raise(error)
  end
end

Lastly, you can define the error handlers as a Symbol shortcut:

# Handle all errors with block response
Rack::Attack.error_handler = :block

# Handle all errors with throttle response
Rack::Attack.error_handler = :throttle

# Handle all errors by allowing the request
Rack::Attack.error_handler = :allow

Testing

A note on developing and testing apps using Rack::Attack - if you are using throttling in particular, you will need to enable the cache in your development environment. See Caching with Rails for more on how to do this.

Disabling

Rack::Attack.enabled = false can be used to either completely disable Rack::Attack in your tests, or to disable/enable for specific test cases only.

Test case isolation

Rack::Attack.reset! can be used in your test suite to clear any Rack::Attack state between different test cases. If you're testing blocklist and safelist configurations, consider using Rack::Attack.clear_configuration to unset the values for those lists between test cases.

How it works

The Rack::Attack middleware compares each request against safelists, blocklists, throttles, and tracks that you define. There are none by default.

  • If the request matches any safelist, it is allowed.
  • Otherwise, if the request matches any blocklist, it is blocked.
  • Otherwise, if the request matches any throttle, a counter is incremented in the Rack::Attack.cache. If any throttle's limit is exceeded, the request is blocked.
  • Otherwise, all tracks are checked, and the request is allowed.

The algorithm is actually more concise in code: See Rack::Attack.call:

def call(env)
  req = Rack::Attack::Request.new(env)

  if safelisted?(req)
    @app.call(env)
  elsif blocklisted?(req)
    self.class.blocklisted_responder.call(req)
  elsif throttled?(req)
    self.class.throttled_responder.call(req)
  else
    tracked?(req)
    @app.call(env)
  end
end

Note: Rack::Attack::Request is just a subclass of Rack::Request so that you can cleanly monkey patch helper methods onto the request object.

About Tracks

Rack::Attack.track doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes.

Performance

The overhead of running Rack::Attack is typically negligible (a few milliseconds per request), but it depends on how many checks you've configured, and how long they take. Throttles usually require a network roundtrip to your cache server(s), so try to keep the number of throttle checks per request low.

If a request is blocklisted or throttled, the response is a very simple Rack response. A single typical ruby web server thread can block several hundred requests per second.

Rack::Attack complements tools like iptables and nginx's limit_conn_zone module.

Motivation

Abusive clients range from malicious login crackers to naively-written scrapers. They hinder the security, performance, & availability of web applications.

It is impractical if not impossible to block abusive clients completely.

Rack::Attack aims to let developers quickly mitigate abusive requests and rely less on short-term, one-off hacks to block a particular attack.

Contributing

Check out the Contributing guide.

Code of Conduct

This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Code of Conduct.

Development setup

Check out the Development guide.

License

Copyright Kickstarter, PBC.

Released under an MIT License.

About

Rack middleware for blocking & throttling

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published

Languages

  • Ruby 100.0%