Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,35 @@ Most applications should use a new, separate database used only for `rack-attack

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](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html). This means that other cache stores which inherit from ActiveSupport::Cache::Store are also compatible. In-memory stores which are not backed by an external database, such as `ActiveSupport::Cache::MemoryStore.new`, will be mostly ineffective because each Ruby process in your deployment will have it's own state, effectively multiplying the number of requests each client can make by the number of Ruby processes you have deployed.

#### Bypassing store errors

By default, some store proxies will swallow the errors they historically rescued (`Redis::BaseConnectionError` for `Redis`, `Dalli::DalliError` for `Dalli`). When one of those errors is raised inside the proxy, the request goes through as if no throttling were applied, which keeps your app available if the dedicated rack-attack store goes down.

You can customize this behavior through `Rack::Attack.cache.bypassable_store_errors`:

```ruby
# Use the proxy's built-in defaults (this is the default)
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: "...")

# Bypass ALL errors raised by the store — requests continue serving even if the
# store misbehaves in unexpected ways (e.g. Redis OOM, timeouts, protocol errors).
Rack::Attack.cache.bypassable_store_errors = :all

# Bypass NO errors — any error from the store will propagate. This disables the
# proxy's historical default rescue behavior as well.
Rack::Attack.cache.bypassable_store_errors = :none

# Bypass a specific list of error classes (or class-name Strings). This REPLACES
# the proxy's built-in defaults - include any you still want to rescue.
Rack::Attack.cache.bypassable_store_errors = [
Redis::BaseConnectionError,
Redis::TimeoutError,
"Redis::CommandError"
]
```

`bypassable_store_errors` can be set before or after assigning `cache.store`; the store is re-wrapped automatically.

## Customizing responses

Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://www.rubydoc.info/github/rack/rack/file/SPEC.rdoc).
Expand Down
65 changes: 65 additions & 0 deletions lib/rack/attack/base_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,71 @@
module Rack
class Attack
class BaseProxy < SimpleDelegator
attr_reader :bypassable_store_errors

def initialize(store, bypassable_store_errors: nil)
super(store)
@bypassable_store_errors = normalize_bypassable_store_errors(bypassable_store_errors)
end

def self.default_bypassable_store_errors
[]
end

protected

def handle_store_error
yield
rescue => e
raise e unless should_bypass_error?(e)
end

private

def should_bypass_error?(error)
case @bypassable_store_errors
when :all
true
when :none
false
else
@bypassable_store_errors.any? { |candidate| error_matches?(error, candidate) }
end
end

def error_matches?(error, candidate)
case candidate
when Class
error.is_a?(candidate)
when String
klass = error.class
while klass
return true if klass.name == candidate

klass = klass.superclass
end
false
end
end

def normalize_bypassable_store_errors(value)
case value
when nil
self.class.default_bypassable_store_errors
when :all, :none
value
when Array
unless value.all? { |e| e.is_a?(Class) || e.is_a?(String) }
raise Rack::Attack::MisconfiguredStoreError,
"bypassable_store_errors Array must contain only Class or String entries"
end
value
else
raise Rack::Attack::MisconfiguredStoreError,
"bypassable_store_errors must be :all, :none, or an Array of error Classes or class name Strings"
end
end

class << self
def proxies
@@proxies ||= []
Expand Down
26 changes: 19 additions & 7 deletions lib/rack/attack/cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module Rack
class Attack
class Cache
attr_accessor :prefix
attr_reader :last_epoch_time
attr_reader :last_epoch_time, :bypassable_store_errors

def self.default_store
if Object.const_defined?(:Rails) && Rails.respond_to?(:cache)
Expand All @@ -13,24 +13,26 @@ def self.default_store
end

def initialize(store: self.class.default_store)
@bypassable_store_errors = nil
self.store = store
@prefix = 'rack::attack'
end

attr_reader :store

def store=(store)
@store =
if (proxy = BaseProxy.lookup(store))
proxy.new(store)
else
store
end
@raw_store = store
@store = wrap_store(store)
if @store
check_store_methods_presence(:read, :write, :delete, :increment)
end
end

def bypassable_store_errors=(value)
@bypassable_store_errors = value
@store = wrap_store(@raw_store) if @raw_store
end

def count(unprefixed_key, period)
key, expires_in = key_and_expiry(unprefixed_key, period)
do_count(key, expires_in)
Expand Down Expand Up @@ -70,6 +72,16 @@ def reset!

private

def wrap_store(store)
return store if store.nil?

if (proxy = BaseProxy.lookup(store))
proxy.new(store, bypassable_store_errors: @bypassable_store_errors)
else
store
end
end

def key_and_expiry(unprefixed_key, period)
@last_epoch_time = Time.now.to_i
# Add 1 to expires_in to avoid timing error: https://github.com/rack/rack-attack/pull/85
Expand Down
22 changes: 10 additions & 12 deletions lib/rack/attack/store_proxy/dalli_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,41 @@ def self.handle?(store)
end
end

def initialize(client)
super(client)
def self.default_bypassable_store_errors
['Dalli::DalliError']
end

def initialize(client, **options)
super(client, **options)
stub_with_if_missing
end

def read(key)
rescuing do
handle_store_error do
with do |client|
client.get(key)
end
end
end

def write(key, value, options = {})
rescuing do
handle_store_error do
with do |client|
client.set(key, value, options.fetch(:expires_in, 0), raw: true)
end
end
end

def increment(key, amount, options = {})
rescuing do
handle_store_error do
with do |client|
client.incr(key, amount, options.fetch(:expires_in, 0), amount)
end
end
end

def delete(key)
rescuing do
handle_store_error do
with do |client|
client.delete(key)
end
Expand All @@ -66,12 +70,6 @@ def with
end
end
end

def rescuing
yield
rescue Dalli::DalliError
nil
end
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/rack/attack/store_proxy/mem_cache_store_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ def self.handle?(store)
end

def read(name, options = {})
super(name, options.merge!(raw: true))
handle_store_error { super(name, options.merge!(raw: true)) }
end

def write(name, value, options = {})
super(name, value, options.merge!(raw: true))
handle_store_error { super(name, value, options.merge!(raw: true)) }
end
end
end
Expand Down
10 changes: 5 additions & 5 deletions lib/rack/attack/store_proxy/redis_cache_store_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,25 @@ def increment(name, amount = 1, **options)
# So in order to workaround this we use RedisCacheStore#write (which sets expiration) to initialize
# the counter. After that we continue using the original RedisCacheStore#increment.
if options[:expires_in] && !read(name)
write(name, amount, options)
handle_store_error { write(name, amount, options) }

amount
else
super
handle_store_error { super }
end
end
end

def read(name, options = {})
super(name, options.merge!(raw: true))
handle_store_error { super(name, options.merge!(raw: true)) }
end

def write(name, value, options = {})
super(name, value, options.merge!(raw: true))
handle_store_error { super(name, value, options.merge!(raw: true)) }
end

def delete_matched(matcher, options = nil)
super(matcher.source, options)
handle_store_error { super(matcher.source, options) }
end
end
end
Expand Down
28 changes: 12 additions & 16 deletions lib/rack/attack/store_proxy/redis_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,36 @@ module Rack
class Attack
module StoreProxy
class RedisProxy < BaseProxy
def initialize(*args)
def initialize(store, **options)
if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3")
warn 'RackAttack requires Redis gem >= 3.0.0.'
end

super(*args)
super(store, **options)
end

def self.handle?(store)
defined?(::Redis) && store.class == ::Redis
end

def self.default_bypassable_store_errors
['Redis::BaseConnectionError']
end

def read(key)
rescuing { get(key) }
handle_store_error { get(key) }
end

def write(key, value, options = {})
if (expires_in = options[:expires_in])
rescuing { setex(key, expires_in, value) }
handle_store_error { setex(key, expires_in, value) }
else
rescuing { set(key, value) }
handle_store_error { set(key, value) }
end
end

def increment(key, amount, options = {})
rescuing do
handle_store_error do
pipelined do |redis|
redis.incrby(key, amount)
redis.expire(key, options[:expires_in]) if options[:expires_in]
Expand All @@ -40,14 +44,14 @@ def increment(key, amount, options = {})
end

def delete(key, _options = {})
rescuing { del(key) }
handle_store_error { del(key) }
end

def delete_matched(matcher, _options = nil)
cursor = "0"
source = matcher.source

rescuing do
handle_store_error do
# Fetch keys in batches using SCAN to avoid blocking the Redis server.
loop do
cursor, keys = scan(cursor, match: source, count: 1000)
Expand All @@ -56,14 +60,6 @@ def delete_matched(matcher, _options = nil)
end
end
end

private

def rescuing
yield
rescue Redis::BaseConnectionError
nil
end
end
end
end
Expand Down
6 changes: 3 additions & 3 deletions lib/rack/attack/store_proxy/redis_store_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ def self.handle?(store)
end

def read(key)
rescuing { get(key, raw: true) }
handle_store_error { get(key, raw: true) }
end

def write(key, value, options = {})
if (expires_in = options[:expires_in])
rescuing { setex(key, expires_in, value, raw: true) }
handle_store_error { setex(key, expires_in, value, raw: true) }
else
rescuing { set(key, value, raw: true) }
handle_store_error { set(key, value, raw: true) }
end
end
end
Expand Down
Loading