rack-attack 4.3.1 → 5.4.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.
Files changed (64) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +230 -113
  3. data/Rakefile +11 -3
  4. data/bin/setup +8 -0
  5. data/lib/rack/attack.rb +121 -48
  6. data/lib/rack/attack/allow2ban.rb +2 -1
  7. data/lib/rack/attack/{whitelist.rb → blocklist.rb} +2 -3
  8. data/lib/rack/attack/cache.rb +24 -5
  9. data/lib/rack/attack/check.rb +6 -8
  10. data/lib/rack/attack/fail2ban.rb +3 -2
  11. data/lib/rack/attack/path_normalizer.rb +6 -11
  12. data/lib/rack/attack/request.rb +1 -1
  13. data/lib/rack/attack/{blacklist.rb → safelist.rb} +2 -4
  14. data/lib/rack/attack/store_proxy.rb +13 -12
  15. data/lib/rack/attack/store_proxy/dalli_proxy.rb +2 -3
  16. data/lib/rack/attack/store_proxy/mem_cache_proxy.rb +50 -0
  17. data/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb +19 -0
  18. data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +35 -0
  19. data/lib/rack/attack/store_proxy/redis_proxy.rb +54 -0
  20. data/lib/rack/attack/store_proxy/redis_store_proxy.rb +5 -24
  21. data/lib/rack/attack/throttle.rb +16 -12
  22. data/lib/rack/attack/track.rb +3 -3
  23. data/lib/rack/attack/version.rb +1 -1
  24. data/spec/acceptance/allow2ban_spec.rb +71 -0
  25. data/spec/acceptance/blocking_ip_spec.rb +38 -0
  26. data/spec/acceptance/blocking_spec.rb +41 -0
  27. data/spec/acceptance/blocking_subnet_spec.rb +44 -0
  28. data/spec/acceptance/cache_store_config_for_allow2ban_spec.rb +126 -0
  29. data/spec/acceptance/cache_store_config_for_fail2ban_spec.rb +121 -0
  30. data/spec/acceptance/cache_store_config_for_throttle_spec.rb +48 -0
  31. data/spec/acceptance/cache_store_config_with_rails_spec.rb +31 -0
  32. data/spec/acceptance/customizing_blocked_response_spec.rb +41 -0
  33. data/spec/acceptance/customizing_throttled_response_spec.rb +59 -0
  34. data/spec/acceptance/extending_request_object_spec.rb +34 -0
  35. data/spec/acceptance/fail2ban_spec.rb +76 -0
  36. data/spec/acceptance/safelisting_ip_spec.rb +48 -0
  37. data/spec/acceptance/safelisting_spec.rb +53 -0
  38. data/spec/acceptance/safelisting_subnet_spec.rb +48 -0
  39. data/spec/acceptance/stores/active_support_dalli_store_spec.rb +19 -0
  40. data/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb +22 -0
  41. data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +18 -0
  42. data/spec/acceptance/stores/active_support_memory_store_spec.rb +16 -0
  43. data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +18 -0
  44. data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +18 -0
  45. data/spec/acceptance/stores/active_support_redis_store_spec.rb +18 -0
  46. data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +22 -0
  47. data/spec/acceptance/stores/dalli_client_spec.rb +19 -0
  48. data/spec/acceptance/stores/redis_spec.rb +20 -0
  49. data/spec/acceptance/stores/redis_store_spec.rb +18 -0
  50. data/spec/acceptance/throttling_spec.rb +159 -0
  51. data/spec/acceptance/track_spec.rb +27 -0
  52. data/spec/acceptance/track_throttle_spec.rb +53 -0
  53. data/spec/allow2ban_spec.rb +10 -9
  54. data/spec/fail2ban_spec.rb +12 -10
  55. data/spec/integration/offline_spec.rb +21 -23
  56. data/spec/rack_attack_dalli_proxy_spec.rb +0 -2
  57. data/spec/rack_attack_request_spec.rb +2 -2
  58. data/spec/rack_attack_spec.rb +53 -18
  59. data/spec/rack_attack_throttle_spec.rb +45 -13
  60. data/spec/rack_attack_track_spec.rb +11 -8
  61. data/spec/spec_helper.rb +35 -14
  62. data/spec/support/cache_store_helper.rb +82 -0
  63. metadata +161 -61
  64. data/spec/integration/rack_attack_cache_spec.rb +0 -119
@@ -0,0 +1,18 @@
1
+ require_relative "../../spec_helper"
2
+
3
+ if defined?(::ConnectionPool) && defined?(::Redis) && Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") && defined?(::ActiveSupport::Cache::RedisCacheStore)
4
+ require_relative "../../support/cache_store_helper"
5
+ require "timecop"
6
+
7
+ describe "ActiveSupport::Cache::RedisCacheStore (pooled) as a cache backend" do
8
+ before do
9
+ Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(pool_size: 2)
10
+ end
11
+
12
+ after do
13
+ Rack::Attack.cache.store.clear
14
+ end
15
+
16
+ it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) })
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../../spec_helper"
2
+
3
+ if defined?(::Redis) && Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") && defined?(::ActiveSupport::Cache::RedisCacheStore)
4
+ require_relative "../../support/cache_store_helper"
5
+ require "timecop"
6
+
7
+ describe "ActiveSupport::Cache::RedisCacheStore as a cache backend" do
8
+ before do
9
+ Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new
10
+ end
11
+
12
+ after do
13
+ Rack::Attack.cache.store.clear
14
+ end
15
+
16
+ it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) })
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../../spec_helper"
2
+
3
+ if defined?(::ActiveSupport::Cache::RedisStore)
4
+ require_relative "../../support/cache_store_helper"
5
+ require "timecop"
6
+
7
+ describe "ActiveSupport::Cache::RedisStore as a cache backend" do
8
+ before do
9
+ Rack::Attack.cache.store = ActiveSupport::Cache::RedisStore.new
10
+ end
11
+
12
+ after do
13
+ Rack::Attack.cache.store.flushdb
14
+ end
15
+
16
+ it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) })
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ require_relative "../../spec_helper"
2
+
3
+ if defined?(::Dalli) && defined?(::ConnectionPool)
4
+ require_relative "../../support/cache_store_helper"
5
+ require "connection_pool"
6
+ require "dalli"
7
+ require "timecop"
8
+
9
+ describe "ConnectionPool with Dalli::Client as a cache backend" do
10
+ before do
11
+ Rack::Attack.cache.store = ConnectionPool.new { Dalli::Client.new }
12
+ end
13
+
14
+ after do
15
+ Rack::Attack.cache.store.with { |client| client.flush_all }
16
+ end
17
+
18
+ it_works_for_cache_backed_features(fetch_from_store: ->(key) {
19
+ Rack::Attack.cache.store.with { |client| client.fetch(key) }
20
+ })
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "../../spec_helper"
2
+
3
+ if defined?(::Dalli)
4
+ require_relative "../../support/cache_store_helper"
5
+ require "dalli"
6
+ require "timecop"
7
+
8
+ describe "Dalli::Client as a cache backend" do
9
+ before do
10
+ Rack::Attack.cache.store = Dalli::Client.new
11
+ end
12
+
13
+ after do
14
+ Rack::Attack.cache.store.flush_all
15
+ end
16
+
17
+ it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) })
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../spec_helper"
4
+
5
+ if defined?(::Redis)
6
+ require_relative "../../support/cache_store_helper"
7
+ require "timecop"
8
+
9
+ describe "Plain redis as a cache backend" do
10
+ before do
11
+ Rack::Attack.cache.store = Redis.new
12
+ end
13
+
14
+ after do
15
+ Rack::Attack.cache.store.flushdb
16
+ end
17
+
18
+ it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.get(key) })
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../../spec_helper"
2
+ require_relative "../../support/cache_store_helper"
3
+
4
+ if defined?(::Redis::Store)
5
+ require "timecop"
6
+
7
+ describe "ActiveSupport::Cache::RedisStore as a cache backend" do
8
+ before do
9
+ Rack::Attack.cache.store = ::Redis::Store.new
10
+ end
11
+
12
+ after do
13
+ Rack::Attack.cache.store.flushdb
14
+ end
15
+
16
+ it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) })
17
+ end
18
+ end
@@ -0,0 +1,159 @@
1
+ require_relative "../spec_helper"
2
+ require "timecop"
3
+
4
+ describe "#throttle" do
5
+ before do
6
+ Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
7
+ end
8
+
9
+ it "allows one request per minute by IP" do
10
+ Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request|
11
+ request.ip
12
+ end
13
+
14
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
15
+
16
+ assert_equal 200, last_response.status
17
+
18
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
19
+
20
+ assert_equal 429, last_response.status
21
+ assert_equal "60", last_response.headers["Retry-After"]
22
+ assert_equal "Retry later\n", last_response.body
23
+
24
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
25
+
26
+ assert_equal 200, last_response.status
27
+
28
+ Timecop.travel(60) do
29
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
30
+
31
+ assert_equal 200, last_response.status
32
+ end
33
+ end
34
+
35
+ it "supports limit to be dynamic" do
36
+ # Could be used to have different rate limits for authorized
37
+ # vs general requests
38
+ limit_proc = lambda do |request|
39
+ if request.env["X-APIKey"] == "private-secret"
40
+ 2
41
+ else
42
+ 1
43
+ end
44
+ end
45
+
46
+ Rack::Attack.throttle("by ip", limit: limit_proc, period: 60) do |request|
47
+ request.ip
48
+ end
49
+
50
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
51
+ assert_equal 200, last_response.status
52
+
53
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
54
+ assert_equal 429, last_response.status
55
+
56
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret"
57
+ assert_equal 200, last_response.status
58
+
59
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret"
60
+ assert_equal 200, last_response.status
61
+
62
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret"
63
+ assert_equal 429, last_response.status
64
+ end
65
+
66
+ it "supports period to be dynamic" do
67
+ # Could be used to have different rate limits for authorized
68
+ # vs general requests
69
+ period_proc = lambda do |request|
70
+ if request.env["X-APIKey"] == "private-secret"
71
+ 10
72
+ else
73
+ 30
74
+ end
75
+ end
76
+
77
+ Rack::Attack.throttle("by ip", limit: 1, period: period_proc) do |request|
78
+ request.ip
79
+ end
80
+
81
+ # Using Time#at to align to start/end of periods exactly
82
+ # to achieve consistenty in different test runs
83
+
84
+ Timecop.travel(Time.at(0)) do
85
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
86
+ assert_equal 200, last_response.status
87
+
88
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
89
+ assert_equal 429, last_response.status
90
+ end
91
+
92
+ Timecop.travel(Time.at(10)) do
93
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
94
+ assert_equal 429, last_response.status
95
+ end
96
+
97
+ Timecop.travel(Time.at(30)) do
98
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
99
+ assert_equal 200, last_response.status
100
+ end
101
+
102
+ Timecop.travel(Time.at(0)) do
103
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret"
104
+ assert_equal 200, last_response.status
105
+
106
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret"
107
+ assert_equal 429, last_response.status
108
+ end
109
+
110
+ Timecop.travel(Time.at(10)) do
111
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret"
112
+ assert_equal 200, last_response.status
113
+ end
114
+ end
115
+
116
+ it "notifies when the request is throttled" do
117
+ Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request|
118
+ request.ip
119
+ end
120
+
121
+ notification_matched = nil
122
+ notification_type = nil
123
+ notification_data = nil
124
+ notification_discriminator = nil
125
+
126
+ ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
127
+ notification_matched = request.env["rack.attack.matched"]
128
+ notification_type = request.env["rack.attack.match_type"]
129
+ notification_data = request.env['rack.attack.match_data']
130
+ notification_discriminator = request.env['rack.attack.match_discriminator']
131
+ end
132
+
133
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
134
+
135
+ assert_equal 200, last_response.status
136
+ assert_nil notification_matched
137
+ assert_nil notification_type
138
+ assert_nil notification_data
139
+ assert_nil notification_discriminator
140
+
141
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
142
+
143
+ assert_equal 200, last_response.status
144
+ assert_nil notification_matched
145
+ assert_nil notification_type
146
+ assert_nil notification_data
147
+ assert_nil notification_discriminator
148
+
149
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
150
+
151
+ assert_equal 429, last_response.status
152
+ assert_equal "by ip", notification_matched
153
+ assert_equal :throttle, notification_type
154
+ assert_equal 60, notification_data[:period]
155
+ assert_equal 1, notification_data[:limit]
156
+ assert_equal 2, notification_data[:count]
157
+ assert_equal "1.2.3.4", notification_discriminator
158
+ end
159
+ end
@@ -0,0 +1,27 @@
1
+ require_relative "../spec_helper"
2
+
3
+ describe "#track" do
4
+ it "notifies when track block returns true" do
5
+ Rack::Attack.track("ip 1.2.3.4") do |request|
6
+ request.ip == "1.2.3.4"
7
+ end
8
+
9
+ notification_matched = nil
10
+ notification_type = nil
11
+
12
+ ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
13
+ notification_matched = request.env["rack.attack.matched"]
14
+ notification_type = request.env["rack.attack.match_type"]
15
+ end
16
+
17
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
18
+
19
+ assert_nil notification_matched
20
+ assert_nil notification_type
21
+
22
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
23
+
24
+ assert_equal "ip 1.2.3.4", notification_matched
25
+ assert_equal :track, notification_type
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ require_relative "../spec_helper"
2
+ require "timecop"
3
+
4
+ describe "#track with throttle-ish options" do
5
+ it "notifies when throttle goes over the limit without actually throttling requests" do
6
+ Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
7
+
8
+ Rack::Attack.track("by ip", limit: 1, period: 60) do |request|
9
+ request.ip
10
+ end
11
+
12
+ notification_matched = nil
13
+ notification_type = nil
14
+
15
+ ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
16
+ notification_matched = request.env["rack.attack.matched"]
17
+ notification_type = request.env["rack.attack.match_type"]
18
+ end
19
+
20
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
21
+
22
+ assert_nil notification_matched
23
+ assert_nil notification_type
24
+
25
+ assert_equal 200, last_response.status
26
+
27
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
28
+
29
+ assert_nil notification_matched
30
+ assert_nil notification_type
31
+
32
+ assert_equal 200, last_response.status
33
+
34
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
35
+
36
+ assert_equal "by ip", notification_matched
37
+ assert_equal :track, notification_type
38
+
39
+ assert_equal 200, last_response.status
40
+
41
+ Timecop.travel(60) do
42
+ notification_matched = nil
43
+ notification_type = nil
44
+
45
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
46
+
47
+ assert_nil notification_matched
48
+ assert_nil notification_type
49
+
50
+ assert_equal 200, last_response.status
51
+ end
52
+ end
53
+ end
@@ -1,4 +1,5 @@
1
1
  require_relative 'spec_helper'
2
+
2
3
  describe 'Rack::Attack.Allow2Ban' do
3
4
  before do
4
5
  # Use a long findtime; failures due to cache key rotation less likely
@@ -6,9 +7,10 @@ describe 'Rack::Attack.Allow2Ban' do
6
7
  @findtime = 60
7
8
  @bantime = 60
8
9
  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/}
10
+ @f2b_options = { :bantime => @bantime, :findtime => @findtime, :maxretry => 2 }
11
+
12
+ Rack::Attack.blocklist('pentest') do |req|
13
+ Rack::Attack::Allow2Ban.filter(req.ip, @f2b_options) { req.query_string =~ /OMGHAX/ }
12
14
  end
13
15
  end
14
16
 
@@ -23,12 +25,13 @@ describe 'Rack::Attack.Allow2Ban' do
23
25
  describe 'making qualifying request' do
24
26
  describe 'when not at maxretry' do
25
27
  before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' }
28
+
26
29
  it 'succeeds' do
27
30
  last_response.status.must_equal 200
28
31
  end
29
32
 
30
33
  it 'increases fail count' do
31
- key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
34
+ key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
32
35
  @cache.store.read(key).must_equal 1
33
36
  end
34
37
 
@@ -50,7 +53,7 @@ describe 'Rack::Attack.Allow2Ban' do
50
53
  end
51
54
 
52
55
  it 'increases fail count' do
53
- key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
56
+ key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
54
57
  @cache.store.read(key).must_equal 2
55
58
  end
56
59
 
@@ -58,7 +61,6 @@ describe 'Rack::Attack.Allow2Ban' do
58
61
  key = "rack::attack:allow2ban:ban:1.2.3.4"
59
62
  @cache.store.read(key).must_equal 1
60
63
  end
61
-
62
64
  end
63
65
  end
64
66
  end
@@ -87,7 +89,7 @@ describe 'Rack::Attack.Allow2Ban' do
87
89
  end
88
90
 
89
91
  it 'does not increase fail count' do
90
- key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
92
+ key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
91
93
  @cache.store.read(key).must_equal 2
92
94
  end
93
95
 
@@ -107,7 +109,7 @@ describe 'Rack::Attack.Allow2Ban' do
107
109
  end
108
110
 
109
111
  it 'does not increase fail count' do
110
- key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
112
+ key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
111
113
  @cache.store.read(key).must_equal 2
112
114
  end
113
115
 
@@ -116,6 +118,5 @@ describe 'Rack::Attack.Allow2Ban' do
116
118
  @cache.store.read(key).must_equal 1
117
119
  end
118
120
  end
119
-
120
121
  end
121
122
  end