wkimeria-rack-attack 4.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +15 -0
- data/README.md +308 -0
- data/Rakefile +18 -0
- data/lib/rack/attack.rb +122 -0
- data/lib/rack/attack/allow2ban.rb +23 -0
- data/lib/rack/attack/blacklist.rb +12 -0
- data/lib/rack/attack/cache.rb +57 -0
- data/lib/rack/attack/check.rb +23 -0
- data/lib/rack/attack/conditional_throttle.rb +17 -0
- data/lib/rack/attack/fail2ban.rb +48 -0
- data/lib/rack/attack/request.rb +19 -0
- data/lib/rack/attack/store_proxy.rb +22 -0
- data/lib/rack/attack/store_proxy/dalli_proxy.rb +65 -0
- data/lib/rack/attack/store_proxy/redis_store_proxy.rb +49 -0
- data/lib/rack/attack/throttle.rb +50 -0
- data/lib/rack/attack/track.rb +21 -0
- data/lib/rack/attack/version.rb +5 -0
- data/lib/rack/attack/whitelist.rb +11 -0
- data/spec/allow2ban_spec.rb +121 -0
- data/spec/fail2ban_spec.rb +121 -0
- data/spec/integration/offline_spec.rb +47 -0
- data/spec/integration/rack_attack_cache_spec.rb +86 -0
- data/spec/rack_attack_conditional_throttle_spec.rb +53 -0
- data/spec/rack_attack_dalli_proxy_spec.rb +10 -0
- data/spec/rack_attack_request_spec.rb +19 -0
- data/spec/rack_attack_spec.rb +50 -0
- data/spec/rack_attack_throttle_spec.rb +64 -0
- data/spec/rack_attack_track_spec.rb +58 -0
- data/spec/spec_helper.rb +40 -0
- metadata +209 -0
@@ -0,0 +1,23 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Allow2Ban < Fail2Ban
|
4
|
+
class << self
|
5
|
+
protected
|
6
|
+
def key_prefix
|
7
|
+
'allow2ban'
|
8
|
+
end
|
9
|
+
|
10
|
+
# everything the same here except we return only return true
|
11
|
+
# (blocking the request) if they have tripped the limit.
|
12
|
+
def fail!(discriminator, bantime, findtime, maxretry)
|
13
|
+
count = cache.count("#{key_prefix}:count:#{discriminator}", findtime)
|
14
|
+
if count >= maxretry
|
15
|
+
ban!(discriminator, bantime)
|
16
|
+
end
|
17
|
+
# we may not block them this time, but they're banned for next time
|
18
|
+
false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Cache
|
4
|
+
|
5
|
+
attr_accessor :prefix
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
self.store = ::Rails.cache if defined?(::Rails.cache)
|
9
|
+
@prefix = 'rack::attack'
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :store
|
13
|
+
def store=(store)
|
14
|
+
@store = StoreProxy.build(store)
|
15
|
+
end
|
16
|
+
|
17
|
+
def get_count(unprefixed_key, period)
|
18
|
+
if store.class == Rack::Attack::StoreProxy::RedisStoreProxy
|
19
|
+
store.raw_read(build_key(unprefixed_key, period))
|
20
|
+
else
|
21
|
+
store.read(build_key(unprefixed_key, period))
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def count(unprefixed_key, period)
|
26
|
+
epoch_time = Time.now.to_i
|
27
|
+
# Add 1 to expires_in to avoid timing error: http://git.io/i1PHXA
|
28
|
+
expires_in = period - (epoch_time % period) + 1
|
29
|
+
do_count(build_key(unprefixed_key, period), expires_in)
|
30
|
+
end
|
31
|
+
|
32
|
+
def read(unprefixed_key)
|
33
|
+
store.read("#{prefix}:#{unprefixed_key}")
|
34
|
+
end
|
35
|
+
|
36
|
+
def write(unprefixed_key, value, expires_in)
|
37
|
+
store.write("#{prefix}:#{unprefixed_key}", value, :expires_in => expires_in)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
def do_count(key, expires_in)
|
42
|
+
result = store.increment(key, 1, :expires_in => expires_in)
|
43
|
+
|
44
|
+
# NB: Some stores return nil when incrementing uninitialized values
|
45
|
+
if result.nil?
|
46
|
+
store.write(key, 1, :expires_in => expires_in)
|
47
|
+
end
|
48
|
+
result || 1
|
49
|
+
end
|
50
|
+
|
51
|
+
def build_key(unprefixed_key, period)
|
52
|
+
epoch_time = Time.now.to_i
|
53
|
+
"#{prefix}:#{(epoch_time/period).to_i}:#{unprefixed_key}"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Check
|
4
|
+
attr_reader :name, :block, :type
|
5
|
+
def initialize(name, options = {}, block)
|
6
|
+
@name, @block = name, block
|
7
|
+
@type = options.fetch(:type, nil)
|
8
|
+
end
|
9
|
+
|
10
|
+
def [](req)
|
11
|
+
block[req].tap {|match|
|
12
|
+
if match
|
13
|
+
req.env["rack.attack.matched"] = name
|
14
|
+
req.env["rack.attack.match_type"] = type
|
15
|
+
Rack::Attack.instrument(req)
|
16
|
+
end
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class ConditionalThrottle < ::Rack::Attack::Throttle
|
4
|
+
|
5
|
+
def increment_counter(discriminator)
|
6
|
+
key = "#{name}:#{discriminator}"
|
7
|
+
cache.count(key, period)
|
8
|
+
end
|
9
|
+
|
10
|
+
def get_count(discriminator)
|
11
|
+
key = "#{name}:#{discriminator}"
|
12
|
+
count = cache.get_count(key, period)
|
13
|
+
count ? count.to_i : 0
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Fail2Ban
|
4
|
+
class << self
|
5
|
+
def filter(discriminator, options)
|
6
|
+
bantime = options[:bantime] or raise ArgumentError, "Must pass bantime option"
|
7
|
+
findtime = options[:findtime] or raise ArgumentError, "Must pass findtime option"
|
8
|
+
maxretry = options[:maxretry] or raise ArgumentError, "Must pass maxretry option"
|
9
|
+
|
10
|
+
if banned?(discriminator)
|
11
|
+
# Return true for blacklist
|
12
|
+
true
|
13
|
+
elsif yield
|
14
|
+
fail!(discriminator, bantime, findtime, maxretry)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
def key_prefix
|
20
|
+
'fail2ban'
|
21
|
+
end
|
22
|
+
|
23
|
+
def fail!(discriminator, bantime, findtime, maxretry)
|
24
|
+
count = cache.count("#{key_prefix}:count:#{discriminator}", findtime)
|
25
|
+
if count >= maxretry
|
26
|
+
ban!(discriminator, bantime)
|
27
|
+
end
|
28
|
+
|
29
|
+
true
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
private
|
34
|
+
def ban!(discriminator, bantime)
|
35
|
+
cache.write("#{key_prefix}:ban:#{discriminator}", 1, bantime)
|
36
|
+
end
|
37
|
+
|
38
|
+
def banned?(discriminator)
|
39
|
+
cache.read("#{key_prefix}:ban:#{discriminator}")
|
40
|
+
end
|
41
|
+
|
42
|
+
def cache
|
43
|
+
Rack::Attack.cache
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# Rack::Attack::Request is the same as ::Rack::Request by default.
|
2
|
+
#
|
3
|
+
# This is a safe place to add custom helper methods to the request object
|
4
|
+
# through monkey patching:
|
5
|
+
#
|
6
|
+
# class Rack::Attack::Request < ::Rack::Request
|
7
|
+
# def localhost?
|
8
|
+
# ip == "127.0.0.1"
|
9
|
+
# end
|
10
|
+
# end
|
11
|
+
#
|
12
|
+
# Rack::Attack.whitelist("localhost") {|req| req.localhost? }
|
13
|
+
#
|
14
|
+
module Rack
|
15
|
+
class Attack
|
16
|
+
class Request < ::Rack::Request
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
module StoreProxy
|
4
|
+
PROXIES = [DalliProxy, RedisStoreProxy]
|
5
|
+
|
6
|
+
def self.build(store)
|
7
|
+
# RedisStore#increment needs different behavior, so detect that
|
8
|
+
# (method has an arity of 2; must call #expire separately
|
9
|
+
if defined?(::ActiveSupport::Cache::RedisStore) && store.is_a?(::ActiveSupport::Cache::RedisStore)
|
10
|
+
# ActiveSupport::Cache::RedisStore doesn't expose any way to set an expiry,
|
11
|
+
# so use the raw Redis::Store instead
|
12
|
+
store = store.instance_variable_get(:@data)
|
13
|
+
end
|
14
|
+
|
15
|
+
klass = PROXIES.find { |proxy| proxy.handle?(store) }
|
16
|
+
|
17
|
+
klass ? klass.new(store) : store
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'delegate'
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
class Attack
|
5
|
+
module StoreProxy
|
6
|
+
class DalliProxy < SimpleDelegator
|
7
|
+
def self.handle?(store)
|
8
|
+
return false unless defined?(::Dalli)
|
9
|
+
|
10
|
+
# Consider extracting to a separate Connection Pool proxy to reduce
|
11
|
+
# code here and handle clients other than Dalli.
|
12
|
+
if defined?(::ConnectionPool) && store.is_a?(::ConnectionPool)
|
13
|
+
store.with { |conn| conn.is_a?(::Dalli::Client) }
|
14
|
+
else
|
15
|
+
store.is_a?(::Dalli::Client)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(client)
|
20
|
+
super(client)
|
21
|
+
stub_with_if_missing
|
22
|
+
end
|
23
|
+
|
24
|
+
def read(key)
|
25
|
+
with do |client|
|
26
|
+
client.get(key)
|
27
|
+
end
|
28
|
+
rescue Dalli::DalliError
|
29
|
+
end
|
30
|
+
|
31
|
+
def write(key, value, options={})
|
32
|
+
with do |client|
|
33
|
+
client.set(key, value, options.fetch(:expires_in, 0), raw: true)
|
34
|
+
end
|
35
|
+
rescue Dalli::DalliError
|
36
|
+
end
|
37
|
+
|
38
|
+
def increment(key, amount, options={})
|
39
|
+
with do |client|
|
40
|
+
client.incr(key, amount, options.fetch(:expires_in, 0), amount)
|
41
|
+
end
|
42
|
+
rescue Dalli::DalliError
|
43
|
+
end
|
44
|
+
|
45
|
+
def delete(key)
|
46
|
+
with do |client|
|
47
|
+
client.delete(key)
|
48
|
+
end
|
49
|
+
rescue Dalli::DalliError
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def stub_with_if_missing
|
55
|
+
unless __getobj__.respond_to?(:with)
|
56
|
+
class << self
|
57
|
+
def with; yield __getobj__; end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'delegate'
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
class Attack
|
5
|
+
module StoreProxy
|
6
|
+
class RedisStoreProxy < SimpleDelegator
|
7
|
+
def self.handle?(store)
|
8
|
+
defined?(::Redis::Store) && store.is_a?(::Redis::Store)
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(store)
|
12
|
+
super(store)
|
13
|
+
end
|
14
|
+
|
15
|
+
def raw_read(key)
|
16
|
+
#https://github.com/redis-store/redis-store/issues/96
|
17
|
+
# if raw not specified results in 'marshal data too short' error
|
18
|
+
self.get(key, raw: true)
|
19
|
+
rescue Redis::BaseError
|
20
|
+
end
|
21
|
+
|
22
|
+
def read(key)
|
23
|
+
self.get(key)
|
24
|
+
rescue Redis::BaseError
|
25
|
+
end
|
26
|
+
|
27
|
+
def write(key, value, options={})
|
28
|
+
if (expires_in = options[:expires_in])
|
29
|
+
self.setex(key, expires_in, value)
|
30
|
+
else
|
31
|
+
self.set(key, value)
|
32
|
+
end
|
33
|
+
rescue Redis::BaseError
|
34
|
+
end
|
35
|
+
|
36
|
+
def increment(key, amount, options={})
|
37
|
+
count = nil
|
38
|
+
self.pipelined do
|
39
|
+
count = self.incrby(key, amount)
|
40
|
+
self.expire(key, options[:expires_in]) if options[:expires_in]
|
41
|
+
end
|
42
|
+
count.value if count
|
43
|
+
rescue Redis::BaseError
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Throttle
|
4
|
+
MANDATORY_OPTIONS = [:limit, :period]
|
5
|
+
attr_reader :name, :limit, :period, :block, :type
|
6
|
+
def initialize(name, options, block)
|
7
|
+
@name, @block = name, block
|
8
|
+
MANDATORY_OPTIONS.each do |opt|
|
9
|
+
raise ArgumentError.new("Must pass #{opt.inspect} option") unless options[opt]
|
10
|
+
end
|
11
|
+
@limit = options[:limit]
|
12
|
+
@period = options[:period].to_i
|
13
|
+
@type = options.fetch(:type, :throttle)
|
14
|
+
end
|
15
|
+
|
16
|
+
def cache
|
17
|
+
Rack::Attack.cache
|
18
|
+
end
|
19
|
+
|
20
|
+
def get_count(discriminator)
|
21
|
+
key = "#{name}:#{discriminator}"
|
22
|
+
cache.count(key, period)
|
23
|
+
end
|
24
|
+
|
25
|
+
def [](req)
|
26
|
+
discriminator = block[req]
|
27
|
+
return false unless discriminator
|
28
|
+
|
29
|
+
count = get_count(discriminator)
|
30
|
+
current_limit = limit.respond_to?(:call) ? limit.call(req) : limit
|
31
|
+
data = {
|
32
|
+
:count => count,
|
33
|
+
:period => period,
|
34
|
+
:limit => current_limit
|
35
|
+
}
|
36
|
+
(req.env['rack.attack.throttle_data'] ||= {})[name] = data
|
37
|
+
|
38
|
+
(count > current_limit).tap do |throttled|
|
39
|
+
if throttled
|
40
|
+
req.env['rack.attack.matched'] = name
|
41
|
+
req.env['rack.attack.match_discriminator'] = discriminator
|
42
|
+
req.env['rack.attack.match_type'] = type
|
43
|
+
req.env['rack.attack.match_data'] = data
|
44
|
+
Rack::Attack.instrument(req)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Track
|
4
|
+
extend Forwardable
|
5
|
+
|
6
|
+
attr_reader :filter
|
7
|
+
|
8
|
+
def initialize(name, options = {}, block)
|
9
|
+
options[:type] = :track
|
10
|
+
|
11
|
+
if options[:limit] && options[:period]
|
12
|
+
@filter = Throttle.new(name, options, block)
|
13
|
+
else
|
14
|
+
@filter = Check.new(name, options, block)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def_delegator :@filter, :[]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
describe 'Rack::Attack.Allow2Ban' do
|
3
|
+
before do
|
4
|
+
# Use a long findtime; failures due to cache key rotation less likely
|
5
|
+
@cache = Rack::Attack.cache
|
6
|
+
@findtime = 60
|
7
|
+
@bantime = 60
|
8
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
9
|
+
@f2b_options = {:bantime => @bantime, :findtime => @findtime, :maxretry => 2}
|
10
|
+
Rack::Attack.blacklist('pentest') do |req|
|
11
|
+
Rack::Attack::Allow2Ban.filter(req.ip, @f2b_options){req.query_string =~ /OMGHAX/}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe 'discriminator has not been banned' do
|
16
|
+
describe 'making ok request' do
|
17
|
+
it 'succeeds' do
|
18
|
+
get '/', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
19
|
+
last_response.status.must_equal 200
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe 'making qualifying request' do
|
24
|
+
describe 'when not at maxretry' do
|
25
|
+
before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
26
|
+
it 'succeeds' do
|
27
|
+
last_response.status.must_equal 200
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'increases fail count' do
|
31
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
|
32
|
+
@cache.store.read(key).must_equal 1
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'is not banned' do
|
36
|
+
key = "rack::attack:allow2ban:1.2.3.4"
|
37
|
+
@cache.store.read(key).must_be_nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe 'when at maxretry' do
|
42
|
+
before do
|
43
|
+
# maxretry is 2 - so hit with an extra failed request first
|
44
|
+
get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
45
|
+
get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'succeeds' do
|
49
|
+
last_response.status.must_equal 200
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'increases fail count' do
|
53
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
|
54
|
+
@cache.store.read(key).must_equal 2
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'is banned' do
|
58
|
+
key = "rack::attack:allow2ban:ban:1.2.3.4"
|
59
|
+
@cache.store.read(key).must_equal 1
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
describe 'discriminator has been banned' do
|
67
|
+
before do
|
68
|
+
# maxretry is 2 - so hit enough times to get banned
|
69
|
+
get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
70
|
+
get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
71
|
+
end
|
72
|
+
|
73
|
+
describe 'making request for other discriminator' do
|
74
|
+
it 'succeeds' do
|
75
|
+
get '/', {}, 'REMOTE_ADDR' => '2.2.3.4'
|
76
|
+
last_response.status.must_equal 200
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe 'making ok request' do
|
81
|
+
before do
|
82
|
+
get '/', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'fails' do
|
86
|
+
last_response.status.must_equal 403
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'does not increase fail count' do
|
90
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
|
91
|
+
@cache.store.read(key).must_equal 2
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'is still banned' do
|
95
|
+
key = "rack::attack:allow2ban:ban:1.2.3.4"
|
96
|
+
@cache.store.read(key).must_equal 1
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
describe 'making failing request' do
|
101
|
+
before do
|
102
|
+
get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
103
|
+
end
|
104
|
+
|
105
|
+
it 'fails' do
|
106
|
+
last_response.status.must_equal 403
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'does not increase fail count' do
|
110
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
|
111
|
+
@cache.store.read(key).must_equal 2
|
112
|
+
end
|
113
|
+
|
114
|
+
it 'is still banned' do
|
115
|
+
key = "rack::attack:allow2ban:ban:1.2.3.4"
|
116
|
+
@cache.store.read(key).must_equal 1
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
end
|