-
Notifications
You must be signed in to change notification settings - Fork 335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
System Hardening Phase 1: Catch errors at top level #639
Changes from 3 commits
7f72d4f
b8f51d0
a854656
944e71a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
|
@@ -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'] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to better understand the need to make this configurable, apart from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some reasons:
That being said I'd estimate 98%+ of users are fine with the default Redis/Dalli error handling. |
||
``` | ||
|
||
## 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 | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -33,7 +33,10 @@ class IncompatibleStoreError < Error; end | |
autoload :Allow2Ban, 'rack/attack/allow2ban' | ||
|
||
class << self | ||
attr_accessor :enabled, :notifier, :throttle_discriminator_normalizer | ||
attr_accessor :enabled, | ||
:notifier, | ||
:throttle_discriminator_normalizer | ||
|
||
attr_reader :configuration | ||
|
||
def instrument(request) | ||
|
@@ -59,6 +62,15 @@ def reset! | |
cache.reset! | ||
end | ||
|
||
def allow_error?(error) | ||
allowed_errors&.any? do |allowed_error| | ||
case allowed_error | ||
when String then error.class.ancestors.any? {|a| a.name == allowed_error } | ||
else error.is_a?(allowed_error) | ||
end | ||
end | ||
end | ||
|
||
extend Forwardable | ||
def_delegators( | ||
:@configuration, | ||
|
@@ -82,11 +94,13 @@ def reset! | |
:safelists, | ||
:blocklists, | ||
:throttles, | ||
:tracks | ||
:tracks, | ||
:allowed_errors, | ||
:allowed_errors= | ||
) | ||
end | ||
|
||
# Set defaults | ||
# Set instance defaults | ||
@enabled = true | ||
@notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications) | ||
@throttle_discriminator_normalizer = lambda do |discriminator| | ||
|
@@ -128,6 +142,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) | ||
Comment on lines
+145
to
+146
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, here we are rescuing rescue *self.allowed_errors => error
@app.call(request.env)
end because I prefer the simplicity of this alternative if possible There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I did it this way so we can define the def allow_error?(error)
allowed_errors&.any? do |allowed_error|
case allowed_error
when String then error.class.ancestors.any? {|a| a.name == allowed_error }
else error.is_a?(allowed_error)
end
end
end There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Agree on this. Not really sold on the I know you didn't like it before, but one way could be the I think this point is really important and we need to take the time to brainstorm/discuss it properly. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @santib In order to invoke def allowed_errors_as_consts
@cached_error_classes ||= {}
allowed_errors.map do |err|
if err.is_a?(String)
@cached_error_classes[err] ||= Object.const_get(err) rescue NameError
else
err
end
end.compact
end This would need to be done dynamically because This is a lot of silly overhead just to achieve the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I know that. But why do we need to also allow There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For reference, there is precedent in other Ruby libs to reference potentially unloaded classes with strings, such as Rails where you can see this: belongs_to :manager, class_name: "Employee" There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Last thing: this is a moot point. Please see these line in the intended final state of this series of PRs: Note I'm adding an additional error_handler here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I would really prefer that we do not obscure the errors coming from Redis/Dalli itself. It makes it harder to understand the reasons why Redis/Dalli are failing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
it could be just a wrapper (as I said in option 2.), and then pass the original error to the application. Having that wrapper would solve the constant issue we are discussing here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's defer this discussion until I get my other PRs merged. There's no reason to bikeshed and introduce complexity at this point. |
||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Haven't looked at the in more detail but would be great if we wouldn't need to add extra dep. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For this PR need some form of mocking in our tests, and RSpec's mock library is widely-used and has minitest integration. In the tests you can see I am stubbing certain methods to raise errors. There is no other way to do this in the specs b/c these error would only occur if certain infra such as Redis is down. The only alternative would be to write monkey patches in the tests, which is far worse/messy. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I get your point, but maybe we can change where this gem is used for something similar to how exceptions are currently being forced in tests, and we avoid introducing a new dependency? Or are there other places where There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please read my It is certainly possible to force certain errors by strange Redis configurations, however this ultimately is limited in what types of errors we can trigger. I will be using mocks further in subsequent PRs. |
||
s.add_development_dependency 'rack-test', "~> 2.0" | ||
s.add_development_dependency 'rake', "~> 13.0" | ||
s.add_development_dependency "rubocop", "1.12.1" | ||
|
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] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we just go with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Rubocop style guide (according to default settings) says a one-element array can be either Since I'm using both 1 and 2 element arrays this test (as well as elsewhere), I think it is better to use See: https://docs.rubocop.org/rubocop/cops_style.html#stylewordarray |
||
|
||
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not strong, thoughts on naming
ignored_errors
insteadallowed_errors
? 🤔There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@grzuy the reason for "allowed" here is that when these errors occur, RackAttack "allows" the request. This naming will be made more obvious in the next PR, which allows you to add error handlers which throttle or block based on certain errors.