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.
@@ -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,12 @@
1
+ module Rack
2
+ class Attack
3
+ class Blacklist < Check
4
+ def initialize(name, block)
5
+ super
6
+ @type = :blacklist
7
+ end
8
+
9
+ end
10
+ end
11
+ end
12
+
@@ -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,5 @@
1
+ module Rack
2
+ class Attack
3
+ VERSION = '4.1.2'
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ module Rack
2
+ class Attack
3
+ class Whitelist < Check
4
+ def initialize(name, block)
5
+ super
6
+ @type = :whitelist
7
+ end
8
+
9
+ end
10
+ end
11
+ 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