Skip to content
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, take two #641

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 21 additions & 3 deletions lib/rack/attack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,21 @@ def initialize(app)
end

def call(env)
return @app.call(env) if !self.class.enabled || env["rack.attack.called"]
@app.call(env) if handle_call(env)
end

private

# Returns true if call should be forwarded to middleware.
def handle_call(env)
return true if !self.class.enabled || env["rack.attack.called"]

env["rack.attack.called"] = true
env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
request = Rack::Attack::Request.new(env)

if configuration.safelisted?(request)
@app.call(env)
return true
elsif configuration.blocklisted?(request)
# Deprecated: Keeping blocklisted_response for backwards compatibility
if configuration.blocklisted_response
Expand All @@ -126,8 +133,19 @@ def call(env)
end
else
configuration.tracked?(request)
@app.call(env)
return true
end

false
rescue *allowed_errors
Copy link
Collaborator

@grzuy grzuy Nov 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, wouldn't this also rescue dalli and redis errors ocurring inside other middlewares down the line called in @app.call(env)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I've refactored the scope which needs error handling into its own method.

true
end

def allowed_errors
errors = []
errors << Dalli::DalliError if defined?(Dalli)
errors << Redis::BaseConnectionError if defined?(Redis)
errors
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
54 changes: 54 additions & 0 deletions spec/acceptance/error_handling_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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 '.call' do
before do
allow(store).to receive(:read).and_raise(raised_error)
end

describe 'when raising Dalli::DalliError' do
let(:raised_error) { stub_const('Dalli::DalliError', Class.new(StandardError)) }

it 'allows the response' do
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"

assert_equal 200, last_response.status
end
end

describe 'when raising Redis::BaseError' do
let(:raised_error) { stub_const('Redis::BaseConnectionError', Class.new(StandardError)) }

it 'allows the response' do
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"

assert_equal 200, last_response.status
end
end

describe 'when raising other error' do
let(:raised_error) { RuntimeError }

it 'raises error if not ignored' do
assert_raises(RuntimeError) do
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
end
end
end
end
end
2 changes: 1 addition & 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