diff --git a/README.md b/README.md index 545003cc..365a9b87 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ 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) +- [Fault Tolerance & Error Handling](#fault-tolerance--error-handling) + - [Built-in error handling](#built-in-error-handling) + - [Expose Rails cache errors to Rack::Attack](#expose-rails-cache-errors-to-rackattack) + - [Configure cache timeout](#configure-cache-timeout) + - [Failure cooldown](#failure-cooldown) + - [Custom error handling](#custom-error-handling) - [Testing](#testing) - [How it works](#how-it-works) - [About Tracks](#about-tracks) @@ -400,11 +406,133 @@ ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, r end ``` +## Fault Tolerance & Error Handling + +Rack::Attack has a mission-critical dependency on your [cache store](#cache-store-configuration). +If the cache system experiences an outage, it may cause severe latency within Rack::Attack +and lead to an overall application outage. + +Although Rack::Attack is designed to be "fault-tolerant by default", depending on your application +setup, additional configuration may be required. Please **read this section carefully** to understand +how to best protect your application. + +### Built-in error handling + +As a Rack middleware component, Rack::Attack wraps your application's request handling endpoint. +When an error occurs within either within Rack::Attack **or** within your application, by default: + +- If the error is a Redis or Dalli cache error, Rack::Attack logs the error then allows the request. +- Otherwise, Rack::Attack 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. + +To mitigate this: + +* When using Rails cache with `:redis_cache_store`, you'll need to expose errors to Rack::Attack +with a custom error handler as follows: + + ```ruby + # 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](#cache-store-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](https://guides.rubyonrails.org/caching_with_rails.html). + +```ruby +# 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](#cache-store-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: + +```ruby +# 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. + +```ruby +# 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. + +```ruby +# Set a custom error handler which blocks allowed errors +# and raises all others +Rack::Attack.error_handler = -> (error, request) do + if Rack::Attack.allow_error?(error) + Rails.logger.warn("Blocking error: #{error.class.name} from IP #{request.ip}") + :block + else + raise(error) + end +end +``` + +Lastly, you can define the error handlers as a Symbol shortcut: + +```ruby +# 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](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 diff --git a/lib/rack/attack.rb b/lib/rack/attack.rb index c9094b21..27590a92 100644 --- a/lib/rack/attack.rb +++ b/lib/rack/attack.rb @@ -32,8 +32,18 @@ class IncompatibleStoreError < Error; end autoload :Fail2Ban, 'rack/attack/fail2ban' autoload :Allow2Ban, 'rack/attack/allow2ban' + THREAD_CALLING_KEY = 'rack.attack.calling' + DEFAULT_FAILURE_COOLDOWN = 60 + 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, + :error_handler, + :allowed_errors, + :failure_cooldown + attr_reader :configuration def instrument(request) @@ -59,6 +69,40 @@ def reset! cache.reset! end + def failed! + @last_failure_at = Time.now + end + + def failure_cooldown? + return false unless @last_failure_at && failure_cooldown + + Time.now < @last_failure_at + failure_cooldown + 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 + + def calling? + !!thread_store[THREAD_CALLING_KEY] + end + + def with_calling + thread_store[THREAD_CALLING_KEY] = true + yield + ensure + thread_store[THREAD_CALLING_KEY] = nil + end + + def thread_store + defined?(RequestStore) ? RequestStore.store : Thread.current + end + extend Forwardable def_delegators( :@configuration, @@ -86,7 +130,11 @@ def reset! ) end - # Set defaults + # Set class defaults + self.failure_cooldown = DEFAULT_FAILURE_COOLDOWN + 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| @@ -102,32 +150,89 @@ def initialize(app) end def call(env) - return @app.call(env) if !self.class.enabled || env["rack.attack.called"] + return @app.call(env) if !self.class.enabled || env["rack.attack.called"] || self.class.failure_cooldown? env["rack.attack.called"] = true env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO']) request = Rack::Attack::Request.new(env) + result = :allow + + self.class.with_calling do + begin + result = get_result(request) + rescue StandardError => error + return do_error_response(error, request) + end + end + + do_response(result, request) + end + + private + def get_result(request) if configuration.safelisted?(request) - @app.call(env) + :allow elsif configuration.blocklisted?(request) - # Deprecated: Keeping blocklisted_response for backwards compatibility - if configuration.blocklisted_response - configuration.blocklisted_response.call(env) - else - configuration.blocklisted_responder.call(request) - end + :block elsif configuration.throttled?(request) - # Deprecated: Keeping throttled_response for backwards compatibility - if configuration.throttled_response - configuration.throttled_response.call(env) - else - configuration.throttled_responder.call(request) - end + :throttle else configuration.tracked?(request) - @app.call(env) + :allow + end + end + + def do_response(result, request) + case result + when :block then do_block_response(request) + when :throttle then do_throttle_response(request) + else @app.call(request.env) + end + end + + def do_block_response(request) + # Deprecated: Keeping blocklisted_response for backwards compatibility + if configuration.blocklisted_response + configuration.blocklisted_response.call(request.env) + else + configuration.blocklisted_responder.call(request) end end + + def do_throttle_response(request) + # Deprecated: Keeping throttled_response for backwards compatibility + if configuration.throttled_response + configuration.throttled_response.call(request.env) + else + configuration.throttled_responder.call(request) + end + end + + def do_error_response(error, request) + self.class.failed! + result = error_result(error, request) + result ? do_response(result, request) : raise(error) + end + + def error_result(error, request) + handler = self.class.error_handler + if handler + error_handler_result(handler, error, request) + elsif self.class.allow_error?(error) + :allow + end + end + + def error_handler_result(handler, error, request) + result = handler + + if handler.is_a?(Proc) + args = [error, request].first(handler.arity) + result = handler.call(*args) # may raise error + end + + %i[block throttle].include?(result) ? result : :allow + end end end diff --git a/lib/rack/attack/store_proxy/dalli_proxy.rb b/lib/rack/attack/store_proxy/dalli_proxy.rb index 48198bb2..b2914ade 100644 --- a/lib/rack/attack/store_proxy/dalli_proxy.rb +++ b/lib/rack/attack/store_proxy/dalli_proxy.rb @@ -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 @@ -66,12 +58,6 @@ def with end end end - - def rescuing - yield - rescue Dalli::DalliError - nil - end end end end diff --git a/lib/rack/attack/store_proxy/redis_proxy.rb b/lib/rack/attack/store_proxy/redis_proxy.rb index 830d39de..e8d833b8 100644 --- a/lib/rack/attack/store_proxy/redis_proxy.rb +++ b/lib/rack/attack/store_proxy/redis_proxy.rb @@ -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 diff --git a/lib/rack/attack/store_proxy/redis_store_proxy.rb b/lib/rack/attack/store_proxy/redis_store_proxy.rb index 28557bcb..7249e1d5 100644 --- a/lib/rack/attack/store_proxy/redis_store_proxy.rb +++ b/lib/rack/attack/store_proxy/redis_store_proxy.rb @@ -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 diff --git a/rack-attack.gemspec b/rack-attack.gemspec index 41cc7a8f..2d5c72f6 100644 --- a/rack-attack.gemspec +++ b/rack-attack.gemspec @@ -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" diff --git a/spec/acceptance/calling_spec.rb b/spec/acceptance/calling_spec.rb new file mode 100644 index 00000000..59d47262 --- /dev/null +++ b/spec/acceptance/calling_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +describe '.with_calling' do + + it 'can specify a calling scope' do + refute Rack::Attack.calling? + assert_nil Thread.current['rack.attack.calling'] + + Rack::Attack.with_calling do + assert Rack::Attack.calling? + assert Thread.current['rack.attack.calling'] + end + + refute Rack::Attack.calling? + assert_nil Thread.current['rack.attack.calling'] + end + + it 'uses RequestStore if available' do + store = double('RequestStore', store: {}) + stub_const('RequestStore', store) + + refute Rack::Attack.calling? + assert_nil Thread.current['rack.attack.calling'] + + Rack::Attack.with_calling do + assert Rack::Attack.calling? + assert store.store['rack.attack.calling'] + assert_nil Thread.current['rack.attack.calling'] + end + + refute Rack::Attack.calling? + assert_nil store.store['rack.attack.calling'] + assert_nil Thread.current['rack.attack.calling'] + end + + it 'is true within error handler scope' do + allow(Rack::Attack.cache.store).to receive(:read).and_raise(RuntimeError) + + Rack::Attack.blocklist("fail2ban pentesters") do |request| + Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 0, bantime: 600, findtime: 30) { true } + end + + error_raised = false + Rack::Attack.error_handler = -> (_error) do + error_raised = true + assert Rack::Attack.calling? + end + + refute Rack::Attack.calling? + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert error_raised + + refute Rack::Attack.calling? + end +end diff --git a/spec/acceptance/error_handling_spec.rb b/spec/acceptance/error_handling_spec.rb new file mode 100644 index 00000000..d624d6e5 --- /dev/null +++ b/spec/acceptance/error_handling_spec.rb @@ -0,0 +1,219 @@ +# 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 + + describe '.error_handler' do + before do + Rack::Attack.error_handler = error_handler if defined?(error_handler) + allow(store).to receive(:read).and_raise(ArgumentError) + end + + it 'can get and set value' do + Rack::Attack.error_handler = :test + assert_equal Rack::Attack.error_handler, :test + end + + describe 'Proc which returns :block' do + let(:error_handler) { ->(_error) { :block } } + + it 'blocks the request' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + end + end + + describe 'Proc which returns :throttle' do + let(:error_handler) { ->(_error) { :throttle } } + + it 'throttles the request' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 429, last_response.status + end + end + + describe 'Proc which returns :allow' do + let(:error_handler) { ->(_error) { :allow } } + + it 'allows the request' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + end + end + + describe 'Proc which returns nil' do + let(:error_handler) { ->(_error) { nil } } + + it 'allows the request' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + end + end + + describe 'Proc which re-raises the error' do + let(:error_handler) { ->(error) { raise error } } + + it 'raises the error' do + assert_raises(ArgumentError) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + end + end + + describe ':block' do + let(:error_handler) { :block } + + it 'blocks the request' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + end + end + + describe ':throttle' do + let(:error_handler) { :throttle } + + it 'throttles the request' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 429, last_response.status + end + end + + describe ':allow' do + let(:error_handler) { :allow } + + it 'allows the request' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + end + end + + describe 'non-nil value' do + let(:error_handler) { true } + + it 'allows the request' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + end + end + + describe 'nil' do + let(:error_handler) { nil } + + it 'raises the error' do + assert_raises(ArgumentError) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + end + end + + describe 'when error ignored' do + let(:error_handler) { :throttle } + + before do + Rack::Attack.allowed_errors = [ArgumentError] + end + + it 'calls handler despite ignored error' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 429, last_response.status + end + end + end +end diff --git a/spec/acceptance/failure_cooldown_spec.rb b/spec/acceptance/failure_cooldown_spec.rb new file mode 100644 index 00000000..437b5388 --- /dev/null +++ b/spec/acceptance/failure_cooldown_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "timecop" + +describe ".failure_cooldown" do + + let(:store) do + ActiveSupport::Cache::MemoryStore.new + end + + let(:ignored_error) do + RuntimeError + end + + before do + Rack::Attack.cache.store = store + Rack::Attack.allowed_errors << ignored_error + + Rack::Attack.blocklist("fail2ban pentesters") do |request| + Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 0, bantime: 600, findtime: 30) { true } + end + end + + it 'has default value' do + assert_equal Rack::Attack.failure_cooldown, 60 + end + + it 'can get and set value' do + Rack::Attack.failure_cooldown = 123 + assert_equal Rack::Attack.failure_cooldown, 123 + end + + it "allows requests for 60 seconds after an internal error" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + + allow(store).to receive(:read).and_raise(ignored_error) + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + + allow(store).to receive(:read).and_call_original + + Timecop.travel(30) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + end + + Timecop.travel(60) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + end + end + + it 'raises non-ignored error' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + + allow(store).to receive(:read).and_raise(ArgumentError) + + assert_raises(ArgumentError) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + end + + describe 'user-defined cooldown value' do + + before do + Rack::Attack.failure_cooldown = 100 + end + + it "allows requests for user-defined period after an internal error" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + + allow(store).to receive(:read).and_raise(ignored_error) + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + + allow(store).to receive(:read).and_call_original + + Timecop.travel(60) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + end + + Timecop.travel(100) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + end + end + end + + describe 'nil' do + + before do + Rack::Attack.failure_cooldown = nil + end + + it 'disables failure cooldown feature' do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + + allow(store).to receive(:read).and_raise(ignored_error) + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + + allow(store).to receive(:read).and_call_original + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + end + end +end + +describe '.failure_cooldown?' do + + it 'returns false if no failure' do + refute Rack::Attack.failure_cooldown? + end + + it 'returns false if failure_cooldown is nil' do + Rack::Attack.failure_cooldown = nil + refute Rack::Attack.failure_cooldown? + end + + it 'returns true if still within cooldown period' do + Rack::Attack.instance_variable_set(:@last_failure_at, Time.now - 30) + assert Rack::Attack.failure_cooldown? + end + + it 'returns false if cooldown period elapsed' do + Rack::Attack.instance_variable_set(:@last_failure_at, Time.now - 61) + refute Rack::Attack.failure_cooldown? + end +end + +describe '.failed!' do + + it 'sets last failure timestamp' do + assert_nil Rack::Attack.instance_variable_get(:@last_failure_at) + Rack::Attack.failed! + refute_nil Rack::Attack.instance_variable_get(:@last_failure_at) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f529e6a1..3a4eb5f9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,6 +4,7 @@ require "minitest/autorun" require "minitest/pride" +require "rspec/mocks/minitest_integration" require "rack/test" require "active_support" require "rack/attack" @@ -35,6 +36,10 @@ class Minitest::Spec after do Rack::Attack.clear_configuration Rack::Attack.instance_variable_set(:@cache, nil) + Rack::Attack.instance_variable_set(:@last_failure_at, nil) + Rack::Attack.error_handler = nil + Rack::Attack.failure_cooldown = Rack::Attack::DEFAULT_FAILURE_COOLDOWN + Rack::Attack.allowed_errors = Rack::Attack::DEFAULT_ALLOWED_ERRORS.dup end def app