rack-attack 5.3.1 → 5.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/Rakefile +3 -2
- data/lib/rack/attack.rb +23 -22
- data/lib/rack/attack/cache.rb +4 -3
- data/lib/rack/attack/check.rb +6 -8
- data/lib/rack/attack/store_proxy.rb +1 -1
- data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +2 -2
- data/lib/rack/attack/store_proxy/redis_proxy.rb +54 -0
- data/lib/rack/attack/store_proxy/redis_store_proxy.rb +1 -22
- data/lib/rack/attack/throttle.rb +14 -11
- data/lib/rack/attack/track.rb +3 -3
- data/lib/rack/attack/version.rb +1 -1
- data/spec/acceptance/stores/active_support_dalli_store_spec.rb +41 -0
- data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +40 -0
- data/spec/acceptance/stores/{mem_cache_store_spec.rb → active_support_memory_store_spec.rb} +5 -5
- data/spec/acceptance/stores/{redis_cache_store_pooled_spec.rb → active_support_redis_cache_store_pooled_spec.rb} +4 -4
- data/spec/acceptance/stores/{redis_cache_store_spec.rb → active_support_redis_cache_store_spec.rb} +4 -4
- data/spec/acceptance/stores/active_support_redis_store_spec.rb +40 -0
- data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +42 -0
- data/spec/acceptance/stores/dalli_client_spec.rb +41 -0
- data/spec/acceptance/stores/redis_spec.rb +42 -0
- data/spec/acceptance/stores/redis_store_spec.rb +40 -0
- data/spec/integration/offline_spec.rb +21 -19
- data/spec/rack_attack_throttle_spec.rb +4 -4
- data/spec/rack_attack_track_spec.rb +4 -4
- data/spec/spec_helper.rb +15 -9
- metadata +84 -146
- data/spec/integration/rack_attack_cache_spec.rb +0 -124
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 123608043cbaa1604ab2d7b06c056010decd274dd4a5b1fe8f2175cec766fa4d
|
4
|
+
data.tar.gz: 2bd3ca91293545b76221608f144c1a43a458fbd9e6f2e8ea2647bf561d8ea9a9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: '06388ad68edc65019740c9ee77347f817b0769e782d0f8564ac5ac6b9c7fa819fe2157a10d89b570c78c14ef5a86fe080fac30aba8ebdfed281f2073785f5685'
|
7
|
+
data.tar.gz: a15cc2cef9eebda52d662fe3eeee3f187d5b575c089a1fa2cf5e0179012499f6440889349a47108c4017f8b73dd18985655dc933baf8c031e08d73ea23d1dbba
|
data/README.md
CHANGED
@@ -303,13 +303,13 @@ Here's an example response that includes conventional `X-RateLimit-*` headers:
|
|
303
303
|
|
304
304
|
```ruby
|
305
305
|
Rack::Attack.throttled_response = lambda do |env|
|
306
|
-
now = Time.now
|
307
306
|
match_data = env['rack.attack.match_data']
|
307
|
+
now = match_data[:epoch_time]
|
308
308
|
|
309
309
|
headers = {
|
310
310
|
'X-RateLimit-Limit' => match_data[:limit].to_s,
|
311
311
|
'X-RateLimit-Remaining' => '0',
|
312
|
-
'X-RateLimit-Reset' => (now + (match_data[:period] - now
|
312
|
+
'X-RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s
|
313
313
|
}
|
314
314
|
|
315
315
|
[ 429, headers, ["Throttled\n"]]
|
@@ -320,7 +320,7 @@ end
|
|
320
320
|
For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
|
321
321
|
|
322
322
|
```ruby
|
323
|
-
request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l }
|
323
|
+
request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l, :epoch_time => t }
|
324
324
|
```
|
325
325
|
|
326
326
|
## Logging & Instrumentation
|
data/Rakefile
CHANGED
data/lib/rack/attack.rb
CHANGED
@@ -17,6 +17,7 @@ class Rack::Attack
|
|
17
17
|
autoload :StoreProxy, 'rack/attack/store_proxy'
|
18
18
|
autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
|
19
19
|
autoload :MemCacheProxy, 'rack/attack/store_proxy/mem_cache_proxy'
|
20
|
+
autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
|
20
21
|
autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
|
21
22
|
autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy'
|
22
23
|
autoload :Fail2Ban, 'rack/attack/fail2ban'
|
@@ -81,40 +82,40 @@ class Rack::Attack
|
|
81
82
|
blocklists
|
82
83
|
end
|
83
84
|
|
84
|
-
def safelisted?(
|
85
|
-
ip_safelists.any? { |safelist| safelist.
|
86
|
-
safelists.any? { |_name, safelist| safelist.
|
85
|
+
def safelisted?(request)
|
86
|
+
ip_safelists.any? { |safelist| safelist.matched_by?(request) } ||
|
87
|
+
safelists.any? { |_name, safelist| safelist.matched_by?(request) }
|
87
88
|
end
|
88
89
|
|
89
|
-
def whitelisted?(
|
90
|
+
def whitelisted?(request)
|
90
91
|
warn "[DEPRECATION] 'Rack::Attack.whitelisted?' is deprecated. Please use 'safelisted?' instead."
|
91
|
-
safelisted?(
|
92
|
+
safelisted?(request)
|
92
93
|
end
|
93
94
|
|
94
|
-
def blocklisted?(
|
95
|
-
ip_blocklists.any? { |blocklist| blocklist.
|
96
|
-
blocklists.any? { |_name, blocklist| blocklist.
|
95
|
+
def blocklisted?(request)
|
96
|
+
ip_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
|
97
|
+
blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
|
97
98
|
end
|
98
99
|
|
99
|
-
def blacklisted?(
|
100
|
+
def blacklisted?(request)
|
100
101
|
warn "[DEPRECATION] 'Rack::Attack.blacklisted?' is deprecated. Please use 'blocklisted?' instead."
|
101
|
-
blocklisted?(
|
102
|
+
blocklisted?(request)
|
102
103
|
end
|
103
104
|
|
104
|
-
def throttled?(
|
105
|
+
def throttled?(request)
|
105
106
|
throttles.any? do |_name, throttle|
|
106
|
-
throttle
|
107
|
+
throttle.matched_by?(request)
|
107
108
|
end
|
108
109
|
end
|
109
110
|
|
110
|
-
def tracked?(
|
111
|
-
tracks.each_value do |
|
112
|
-
|
111
|
+
def tracked?(request)
|
112
|
+
tracks.each_value do |track|
|
113
|
+
track.matched_by?(request)
|
113
114
|
end
|
114
115
|
end
|
115
116
|
|
116
|
-
def instrument(
|
117
|
-
notifier.instrument('rack.attack',
|
117
|
+
def instrument(request)
|
118
|
+
notifier.instrument('rack.attack', request) if notifier
|
118
119
|
end
|
119
120
|
|
120
121
|
def cache
|
@@ -167,16 +168,16 @@ class Rack::Attack
|
|
167
168
|
|
168
169
|
def call(env)
|
169
170
|
env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
|
170
|
-
|
171
|
+
request = Rack::Attack::Request.new(env)
|
171
172
|
|
172
|
-
if safelisted?(
|
173
|
+
if safelisted?(request)
|
173
174
|
@app.call(env)
|
174
|
-
elsif blocklisted?(
|
175
|
+
elsif blocklisted?(request)
|
175
176
|
self.class.blocklisted_response.call(env)
|
176
|
-
elsif throttled?(
|
177
|
+
elsif throttled?(request)
|
177
178
|
self.class.throttled_response.call(env)
|
178
179
|
else
|
179
|
-
tracked?(
|
180
|
+
tracked?(request)
|
180
181
|
@app.call(env)
|
181
182
|
end
|
182
183
|
end
|
data/lib/rack/attack/cache.rb
CHANGED
@@ -2,6 +2,7 @@ module Rack
|
|
2
2
|
class Attack
|
3
3
|
class Cache
|
4
4
|
attr_accessor :prefix
|
5
|
+
attr_reader :last_epoch_time
|
5
6
|
|
6
7
|
def initialize
|
7
8
|
self.store = ::Rails.cache if defined?(::Rails.cache)
|
@@ -41,10 +42,10 @@ module Rack
|
|
41
42
|
private
|
42
43
|
|
43
44
|
def key_and_expiry(unprefixed_key, period)
|
44
|
-
|
45
|
+
@last_epoch_time = Time.now.to_i
|
45
46
|
# Add 1 to expires_in to avoid timing error: https://git.io/i1PHXA
|
46
|
-
expires_in = (period - (
|
47
|
-
["#{prefix}:#{(
|
47
|
+
expires_in = (period - (@last_epoch_time % period) + 1).to_i
|
48
|
+
["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in]
|
48
49
|
end
|
49
50
|
|
50
51
|
def do_count(key, expires_in)
|
data/lib/rack/attack/check.rb
CHANGED
@@ -7,17 +7,15 @@ module Rack
|
|
7
7
|
@type = options.fetch(:type, nil)
|
8
8
|
end
|
9
9
|
|
10
|
-
def
|
11
|
-
block
|
10
|
+
def matched_by?(request)
|
11
|
+
block.call(request).tap do |match|
|
12
12
|
if match
|
13
|
-
|
14
|
-
|
15
|
-
Rack::Attack.instrument(
|
13
|
+
request.env["rack.attack.matched"] = name
|
14
|
+
request.env["rack.attack.match_type"] = type
|
15
|
+
Rack::Attack.instrument(request)
|
16
16
|
end
|
17
|
-
|
17
|
+
end
|
18
18
|
end
|
19
|
-
|
20
|
-
alias_method :match?, :[]
|
21
19
|
end
|
22
20
|
end
|
23
21
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module Rack
|
2
2
|
class Attack
|
3
3
|
module StoreProxy
|
4
|
-
PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy, RedisCacheStoreProxy].freeze
|
4
|
+
PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy, RedisProxy, RedisCacheStoreProxy].freeze
|
5
5
|
|
6
6
|
ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore', 'ActiveSupport::Cache::RedisCacheStore']).freeze
|
7
7
|
ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Dalli::Client', 'MemCache']).freeze
|
@@ -5,7 +5,7 @@ module Rack
|
|
5
5
|
module StoreProxy
|
6
6
|
class RedisCacheStoreProxy < SimpleDelegator
|
7
7
|
def self.handle?(store)
|
8
|
-
defined?(::ActiveSupport::Cache::RedisCacheStore) && store.is_a?(::ActiveSupport::Cache::RedisCacheStore)
|
8
|
+
defined?(::Redis) && defined?(::ActiveSupport::Cache::RedisCacheStore) && store.is_a?(::ActiveSupport::Cache::RedisCacheStore)
|
9
9
|
end
|
10
10
|
|
11
11
|
def increment(name, amount = 1, options = {})
|
@@ -16,7 +16,7 @@ module Rack
|
|
16
16
|
if options[:expires_in] && !read(name)
|
17
17
|
write(name, amount, options)
|
18
18
|
|
19
|
-
|
19
|
+
amount
|
20
20
|
else
|
21
21
|
super
|
22
22
|
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'delegate'
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
class Attack
|
7
|
+
module StoreProxy
|
8
|
+
class RedisProxy < SimpleDelegator
|
9
|
+
def initialize(*args)
|
10
|
+
if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3")
|
11
|
+
warn 'RackAttack requires Redis gem >= 3.0.0.'
|
12
|
+
end
|
13
|
+
|
14
|
+
super(*args)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.handle?(store)
|
18
|
+
defined?(::Redis) && store.is_a?(::Redis)
|
19
|
+
end
|
20
|
+
|
21
|
+
def read(key)
|
22
|
+
get(key)
|
23
|
+
rescue Redis::BaseError
|
24
|
+
end
|
25
|
+
|
26
|
+
def write(key, value, options = {})
|
27
|
+
if (expires_in = options[:expires_in])
|
28
|
+
setex(key, expires_in, value)
|
29
|
+
else
|
30
|
+
set(key, value)
|
31
|
+
end
|
32
|
+
rescue Redis::BaseError
|
33
|
+
end
|
34
|
+
|
35
|
+
def increment(key, amount, options = {})
|
36
|
+
count = nil
|
37
|
+
|
38
|
+
pipelined do
|
39
|
+
count = incrby(key, amount)
|
40
|
+
expire(key, options[:expires_in]) if options[:expires_in]
|
41
|
+
end
|
42
|
+
|
43
|
+
count.value if count
|
44
|
+
rescue Redis::BaseError
|
45
|
+
end
|
46
|
+
|
47
|
+
def delete(key, _options = {})
|
48
|
+
del(key)
|
49
|
+
rescue Redis::BaseError
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -3,15 +3,11 @@ require 'delegate'
|
|
3
3
|
module Rack
|
4
4
|
class Attack
|
5
5
|
module StoreProxy
|
6
|
-
class RedisStoreProxy <
|
6
|
+
class RedisStoreProxy < RedisProxy
|
7
7
|
def self.handle?(store)
|
8
8
|
defined?(::Redis::Store) && store.is_a?(::Redis::Store)
|
9
9
|
end
|
10
10
|
|
11
|
-
def initialize(store)
|
12
|
-
super(store)
|
13
|
-
end
|
14
|
-
|
15
11
|
def read(key)
|
16
12
|
get(key, raw: true)
|
17
13
|
rescue Redis::BaseError
|
@@ -25,23 +21,6 @@ module Rack
|
|
25
21
|
end
|
26
22
|
rescue Redis::BaseError
|
27
23
|
end
|
28
|
-
|
29
|
-
def increment(key, amount, options = {})
|
30
|
-
count = nil
|
31
|
-
|
32
|
-
pipelined do
|
33
|
-
count = incrby(key, amount)
|
34
|
-
expire(key, options[:expires_in]) if options[:expires_in]
|
35
|
-
end
|
36
|
-
|
37
|
-
count.value if count
|
38
|
-
rescue Redis::BaseError
|
39
|
-
end
|
40
|
-
|
41
|
-
def delete(key, _options = {})
|
42
|
-
del(key)
|
43
|
-
rescue Redis::BaseError
|
44
|
-
end
|
45
24
|
end
|
46
25
|
end
|
47
26
|
end
|
data/lib/rack/attack/throttle.rb
CHANGED
@@ -18,29 +18,32 @@ module Rack
|
|
18
18
|
Rack::Attack.cache
|
19
19
|
end
|
20
20
|
|
21
|
-
def
|
22
|
-
discriminator = block
|
21
|
+
def matched_by?(request)
|
22
|
+
discriminator = block.call(request)
|
23
23
|
return false unless discriminator
|
24
24
|
|
25
|
-
current_period = period.respond_to?(:call) ? period.call(
|
26
|
-
current_limit = limit.respond_to?(:call) ? limit.call(
|
25
|
+
current_period = period.respond_to?(:call) ? period.call(request) : period
|
26
|
+
current_limit = limit.respond_to?(:call) ? limit.call(request) : limit
|
27
27
|
key = "#{name}:#{discriminator}"
|
28
28
|
count = cache.count(key, current_period)
|
29
|
+
epoch_time = cache.last_epoch_time
|
29
30
|
|
30
31
|
data = {
|
31
32
|
:count => count,
|
32
33
|
:period => current_period,
|
33
|
-
:limit => current_limit
|
34
|
+
:limit => current_limit,
|
35
|
+
:epoch_time => epoch_time
|
34
36
|
}
|
35
|
-
|
37
|
+
|
38
|
+
(request.env['rack.attack.throttle_data'] ||= {})[name] = data
|
36
39
|
|
37
40
|
(count > current_limit).tap do |throttled|
|
38
41
|
if throttled
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
Rack::Attack.instrument(
|
42
|
+
request.env['rack.attack.matched'] = name
|
43
|
+
request.env['rack.attack.match_discriminator'] = discriminator
|
44
|
+
request.env['rack.attack.match_type'] = type
|
45
|
+
request.env['rack.attack.match_data'] = data
|
46
|
+
Rack::Attack.instrument(request)
|
44
47
|
end
|
45
48
|
end
|
46
49
|
end
|
data/lib/rack/attack/track.rb
CHANGED
@@ -1,8 +1,6 @@
|
|
1
1
|
module Rack
|
2
2
|
class Attack
|
3
3
|
class Track
|
4
|
-
extend Forwardable
|
5
|
-
|
6
4
|
attr_reader :filter
|
7
5
|
|
8
6
|
def initialize(name, options = {}, block)
|
@@ -15,7 +13,9 @@ module Rack
|
|
15
13
|
end
|
16
14
|
end
|
17
15
|
|
18
|
-
|
16
|
+
def matched_by?(request)
|
17
|
+
filter.matched_by?(request)
|
18
|
+
end
|
19
19
|
end
|
20
20
|
end
|
21
21
|
end
|
data/lib/rack/attack/version.rb
CHANGED
@@ -0,0 +1,41 @@
|
|
1
|
+
require_relative "../../spec_helper"
|
2
|
+
|
3
|
+
if defined?(::Dalli)
|
4
|
+
require_relative "../../support/cache_store_helper"
|
5
|
+
require "active_support/cache/dalli_store"
|
6
|
+
require "timecop"
|
7
|
+
|
8
|
+
describe "ActiveSupport::Cache::DalliStore as a cache backend" do
|
9
|
+
before do
|
10
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::DalliStore.new
|
11
|
+
end
|
12
|
+
|
13
|
+
after do
|
14
|
+
Rack::Attack.cache.store.clear
|
15
|
+
end
|
16
|
+
|
17
|
+
it_works_for_cache_backed_features
|
18
|
+
|
19
|
+
it "doesn't leak keys" do
|
20
|
+
Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request|
|
21
|
+
request.ip
|
22
|
+
end
|
23
|
+
|
24
|
+
key = nil
|
25
|
+
|
26
|
+
# Freeze time during these statement to be sure that the key used by rack attack is the same
|
27
|
+
# we pre-calculate in local variable `key`
|
28
|
+
Timecop.freeze do
|
29
|
+
key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4"
|
30
|
+
|
31
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
32
|
+
end
|
33
|
+
|
34
|
+
assert Rack::Attack.cache.store.fetch(key)
|
35
|
+
|
36
|
+
sleep 2.1
|
37
|
+
|
38
|
+
assert_nil Rack::Attack.cache.store.fetch(key)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require_relative "../../spec_helper"
|
2
|
+
|
3
|
+
if defined?(::Dalli)
|
4
|
+
require_relative "../../support/cache_store_helper"
|
5
|
+
require "timecop"
|
6
|
+
|
7
|
+
describe "ActiveSupport::Cache::MemCacheStore as a cache backend" do
|
8
|
+
before do
|
9
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::MemCacheStore.new
|
10
|
+
end
|
11
|
+
|
12
|
+
after do
|
13
|
+
Rack::Attack.cache.store.flush_all
|
14
|
+
end
|
15
|
+
|
16
|
+
it_works_for_cache_backed_features
|
17
|
+
|
18
|
+
it "doesn't leak keys" do
|
19
|
+
Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request|
|
20
|
+
request.ip
|
21
|
+
end
|
22
|
+
|
23
|
+
key = nil
|
24
|
+
|
25
|
+
# Freeze time during these statement to be sure that the key used by rack attack is the same
|
26
|
+
# we pre-calculate in local variable `key`
|
27
|
+
Timecop.freeze do
|
28
|
+
key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4"
|
29
|
+
|
30
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
31
|
+
end
|
32
|
+
|
33
|
+
assert Rack::Attack.cache.store.get(key)
|
34
|
+
|
35
|
+
sleep 2.1
|
36
|
+
|
37
|
+
assert_nil Rack::Attack.cache.store.get(key)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|