Skip to content

Commit

Permalink
This PR removes the rescuing blocks from Dalli/RedisProxy classes a…
Browse files Browse the repository at this point in the history
…nd instead catches errors at the top level.
  • Loading branch information
johnnyshields committed Nov 16, 2023
1 parent 27ada96 commit 7f72d4f
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 56 deletions.
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-ha
- [Customizing responses](#customizing-responses)
- [RateLimit headers for well-behaved clients](#ratelimit-headers-for-well-behaved-clients)
- [Logging & Instrumentation](#logging--instrumentation)
- [Custom Error Handling](#custom-error-handling)
- [Testing](#testing)
- [How it works](#how-it-works)
- [About Tracks](#about-tracks)
Expand Down Expand Up @@ -400,11 +401,21 @@ ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, r
end
```

## Custom Error Handling

You may specify the list of internal errors to allow (i.e. not raise an error)
as either an array of Class and/or String values.

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

## 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](http://guides.rubyonrails.org/caching_with_rails.html)
for more on how to do this.
When developing and testing apps using Rack::Attack, if you are using throttling in particular,
you must enable the cache in your development environment. See
[Caching with Rails](http://guides.rubyonrails.org/caching_with_rails.html) for how to do this.

### Disabling

Expand Down
24 changes: 22 additions & 2 deletions lib/rack/attack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,14 @@ class IncompatibleStoreError < Error; end
autoload :Fail2Ban, 'rack/attack/fail2ban'
autoload :Allow2Ban, 'rack/attack/allow2ban'

DEFAULT_ALLOWED_ERRORS = %w[Dalli::DalliError Redis::BaseError].freeze

class << self
attr_accessor :enabled, :notifier, :throttle_discriminator_normalizer
attr_accessor :enabled,
:notifier,
:throttle_discriminator_normalizer,
:allowed_errors

attr_reader :configuration

def instrument(request)
Expand All @@ -59,6 +65,15 @@ def reset!
cache.reset!
end

def allow_error?(error)
allowed_errors&.any? do |ignored_error|
case ignored_error
when String then error.class.ancestors.any? {|a| a.name == ignored_error }
else error.is_a?(ignored_error)
end
end
end

extend Forwardable
def_delegators(
:@configuration,
Expand Down Expand Up @@ -86,7 +101,10 @@ def reset!
)
end

# Set defaults
# Set class defaults
self.allowed_errors = DEFAULT_ALLOWED_ERRORS.dup

# Set instance defaults
@enabled = true
@notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
@throttle_discriminator_normalizer = lambda do |discriminator|
Expand Down Expand Up @@ -128,6 +146,8 @@ def call(env)
configuration.tracked?(request)
@app.call(env)
end
rescue StandardError => error
self.class.allow_error?(error) ? @app.call(request.env) : raise(error)
end
end
end
30 changes: 8 additions & 22 deletions lib/rack/attack/store_proxy/dalli_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,34 +24,26 @@ def initialize(client)
end

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

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

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

def delete(key)
rescuing do
with do |client|
client.delete(key)
end
with do |client|
client.delete(key)
end
end

Expand All @@ -66,12 +58,6 @@ def with
end
end
end

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

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

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

def increment(key, amount, options = {})
rescuing do
pipelined do |redis|
redis.incrby(key, amount)
redis.expire(key, options[:expires_in]) if options[:expires_in]
end.first
end
pipelined do |redis|
redis.incrby(key, amount)
redis.expire(key, options[:expires_in]) if options[:expires_in]
end.first
end

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

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

rescuing do
# Fetch keys in batches using SCAN to avoid blocking the Redis server.
loop do
cursor, keys = scan(cursor, match: matcher, count: 1000)
del(*keys) unless keys.empty?
break if cursor == "0"
end
# Fetch keys in batches using SCAN to avoid blocking the Redis server.
loop do
cursor, keys = scan(cursor, match: matcher, count: 1000)
del(*keys) unless keys.empty?
break if cursor == "0"
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) }
get(key, raw: true)
end

def write(key, value, options = {})
if (expires_in = options[:expires_in])
rescuing { setex(key, expires_in, value, raw: true) }
setex(key, expires_in, value, raw: true)
else
rescuing { set(key, value, raw: true) }
set(key, value, raw: true)
end
end
end
Expand Down
1 change: 1 addition & 0 deletions rack-attack.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Gem::Specification.new do |s|
s.add_development_dependency "bundler", ">= 1.17", "< 3.0"
s.add_development_dependency 'minitest', "~> 5.11"
s.add_development_dependency "minitest-stub-const", "~> 0.6"
s.add_development_dependency 'rspec-mocks', '~> 3.11.0'
s.add_development_dependency 'rack-test', "~> 2.0"
s.add_development_dependency 'rake', "~> 13.0"
s.add_development_dependency "rubocop", "1.12.1"
Expand Down
93 changes: 93 additions & 0 deletions spec/acceptance/error_handling_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# frozen_string_literal: true

require_relative "../spec_helper"

describe "error handling" do

let(:store) do
ActiveSupport::Cache::MemoryStore.new
end

before do
Rack::Attack.cache.store = store

Rack::Attack.blocklist("fail2ban pentesters") do |request|
Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 0, bantime: 600, findtime: 30) { true }
end
end

describe '.allowed_errors' do
before do
allow(store).to receive(:read).and_raise(RuntimeError)
end

it 'has default value' do
assert_equal Rack::Attack.allowed_errors, %w[Dalli::DalliError Redis::BaseError]
end

it 'can get and set value' do
Rack::Attack.allowed_errors = %w[Foobar]
assert_equal Rack::Attack.allowed_errors, %w[Foobar]
end

it 'can ignore error as Class' do
Rack::Attack.allowed_errors = [RuntimeError]

get "/", {}, "REMOTE_ADDR" => "1.2.3.4"

assert_equal 200, last_response.status
end

it 'can ignore error ancestor as Class' do
Rack::Attack.allowed_errors = [StandardError]

get "/", {}, "REMOTE_ADDR" => "1.2.3.4"

assert_equal 200, last_response.status
end

it 'can ignore error as String' do
Rack::Attack.allowed_errors = %w[RuntimeError]

get "/", {}, "REMOTE_ADDR" => "1.2.3.4"

assert_equal 200, last_response.status
end

it 'can ignore error error ancestor as String' do
Rack::Attack.allowed_errors = %w[StandardError]

get "/", {}, "REMOTE_ADDR" => "1.2.3.4"

assert_equal 200, last_response.status
end

it 'raises error if not ignored' do
assert_raises(RuntimeError) do
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
end
end
end

describe '.allowed_errors?' do

it 'can match String or Class' do
Rack::Attack.allowed_errors = ['ArgumentError', RuntimeError]
assert Rack::Attack.allow_error?(ArgumentError.new)
assert Rack::Attack.allow_error?(RuntimeError.new)
refute Rack::Attack.allow_error?(StandardError.new)
end

it 'can match Class ancestors' do
Rack::Attack.allowed_errors = [StandardError]
assert Rack::Attack.allow_error?(ArgumentError.new)
refute Rack::Attack.allow_error?(Exception.new)
end

it 'can match String ancestors' do
Rack::Attack.allowed_errors = ['StandardError']
assert Rack::Attack.allow_error?(ArgumentError.new)
refute Rack::Attack.allow_error?(Exception.new)
end
end
end
3 changes: 2 additions & 1 deletion spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require "bundler/setup"

require "minitest/autorun"
require "minitest/pride"
require "rspec/mocks/minitest_integration"
require "rack/test"
require "active_support"
require "rack/attack"
Expand Down Expand Up @@ -35,6 +35,7 @@ class Minitest::Spec
after do
Rack::Attack.clear_configuration
Rack::Attack.instance_variable_set(:@cache, nil)
Rack::Attack.allowed_errors = Rack::Attack::DEFAULT_ALLOWED_ERRORS.dup
end

def app
Expand Down

0 comments on commit 7f72d4f

Please sign in to comment.