rack-dedos 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69f8976f9ee4fc15e9c0b3915020919fa72014acb8577933a5bdcd02b674570c
4
- data.tar.gz: 31bbb973f18eee8e22ed576a12846f6443d1ab2106fa3b8a1cdf6b52cc374464
3
+ metadata.gz: 35a9da808f1fbf56cdf83862a982771065b0630bbec3b4bc4b45ee787f4b57cd
4
+ data.tar.gz: 858380d7644f92d28f15ead5c41cb796118fbf0977bad9e19375a3b45befcbec
5
5
  SHA512:
6
- metadata.gz: f506c9e93a4eb859a79c74c2053e493455aa13ebe8487fc42650a59bdb3e65da862b02cfab1cbfc54cb2186e340e014547bf6df4caf4f0afe2eae7233f3c9c1a
7
- data.tar.gz: 9a18dfe754ea786cbef049ec91a11108fb36c272ce954190331612089412957f3056f0635f6bf520a60ff0ab3373678067f6fcc5a7cf4e05799b170a6139a26f
6
+ metadata.gz: 84ce070ddd54d01491e0c6437cc25514a6ff495fc6dfb4fcae2d42729c0619183c78cb20b53bee033d3abe62749c865be903c941976559b14f17d82752979f44
7
+ data.tar.gz: 220d0ac5538ca7dde09826f03b231d7e6bb7830d59dad0efa29a54fae6124acec991d8b0a68a5c384f43af81cf3cdc279918bd3ab6bca2308ba55029a7487944
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  Nothing so far
4
4
 
5
+ ## 0.2.0
6
+
7
+ #### Changes
8
+
9
+ * Determine real client IP
10
+ * Drop autoload and put filters in proper namespace
11
+
5
12
  ## 0.1.0
6
13
 
7
14
  #### Initial implementation
data/README.md CHANGED
@@ -83,11 +83,11 @@ use Rack::Dedos,
83
83
 
84
84
  ## Filters
85
85
 
86
- By default, all filters described below are applied. You can exclude exclude certain filters:
86
+ By default, all filters described below are applied. You can exclude certain filters:
87
87
 
88
88
  ```ruby
89
89
  use Rack::Dedos,
90
- exclude: [:user_agent]
90
+ except: [:user_agent]
91
91
  ```
92
92
 
93
93
  To only apply one specific filter, use the corresponding class as shown below.
@@ -95,7 +95,7 @@ To only apply one specific filter, use the corresponding class as shown below.
95
95
  ### User Agent Filter
96
96
 
97
97
  ```ruby
98
- use Rack::Dedos::UserAgent,
98
+ use Rack::Dedos::Filters::UserAgent,
99
99
  cache_url: 'redis://redis.example.com:6379/12', # db 12 on default port
100
100
  cache_key_prefix: 'dedos', # key prefix for shared caches (default: nil)
101
101
  cache_period: 1800 # seconds (default: 900)
@@ -111,7 +111,7 @@ The following cache backends are supported:
111
111
  ### Country Filter
112
112
 
113
113
  ```ruby
114
- use Rack::Dedos::Country,
114
+ use Rack::Dedos::Filters::Country,
115
115
  maxmind_db_file: '/var/db/maxmind/GeoLite2-Country.mmdb',
116
116
  allowed_countries: %i(AT CH DE),
117
117
  denied_countries: %i(RU)
@@ -137,6 +137,21 @@ tar -xz -C /tmp -f /tmp/geoipupdate.tgz
137
137
  /tmp/geoipupdate_${version}_${arch}/geoipupdate -f "${conf}" -d "${dir}"
138
138
  ```
139
139
 
140
+ ## Real Client IP
141
+
142
+ A word on how the real client IP is determined. Both Rack 2 and Rack 3 (up to 3.0.7 at the time of writing) may populate the request `ip` incorrectly. Here's what a minimalistic Rack app deloyed to Render (behind Cloudflare) reports:
143
+
144
+ > request.ip = 172.71.135.17
145
+ > request.forwarded_for = ["81.XXX.XXX.XXX", "172.71.135.17", "10.201.229.136"]
146
+
147
+ Obviously, the reported IP 172.71.135.17 is not the real client IP, the correct one is the (redacted) 81.XXX.XXX.XXX.
148
+
149
+ Due to this flaw, Rack::Dedos determines the real client IP as follows in order of priority:
150
+
151
+ 1. [`Cf-Connecting-Ip` header](https://developers.cloudflare.com/fundamentals/get-started/reference/http-request-headers/#cf-connecting-ip)
152
+ 2. First entry of the [`X-Forwarded-For` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For)
153
+ 3. [`ip` reported by Rack](https://github.com/rack/rack/blob/main/lib/rack/request.rb)
154
+
140
155
  ## Development
141
156
 
142
157
  For all required test fixtures to be present, you have to check out the repo
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module Dedos
5
+ module Filters
6
+ class Base
7
+
8
+ DEFAULT_OPTIONS = {
9
+ status: 403,
10
+ text: 'Forbidden (Temporarily Blocked by Rules)'
11
+ }
12
+
13
+ attr_reader :app
14
+ attr_reader :options
15
+
16
+ # @param app [#call]
17
+ # @param options [Hash{Symbol => Object}]
18
+ def initialize(app, options = {})
19
+ @app = app
20
+ @options = DEFAULT_OPTIONS.merge(options)
21
+ end
22
+
23
+ def call(env)
24
+ request = Rack::Request.new(env)
25
+ ip = real_ip(request)
26
+ if allowed?(request, ip)
27
+ app.call(env)
28
+ else
29
+ warn("rack-dedos: request from #{ip} blocked by #{self.class} `#{@country_code.inspect}'")
30
+ [options[:status], { 'Content-Type' => 'text/plain' }, [options[:text]]]
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def config
37
+ Rack::Dedos.config
38
+ end
39
+
40
+ # Get the real IP of the client
41
+ #
42
+ # If a proxy such as Cloudflare is in the mix, the client IP reported
43
+ # by Rack may be wrong. Therefore, we determine the real client IP
44
+ # using the following priorities:
45
+ #
46
+ # 1. Cf-Connecting-Ip header
47
+ # 2. X-Forwarded-For header (also remove port number)
48
+ # 3. IP reported by Rack
49
+ #
50
+ # @param request [Rack::Request]
51
+ # @return [String, nil] real client IP or +nil+ if X-Forwarded-For is
52
+ # not set
53
+ def real_ip(request)
54
+ case
55
+ when ip = request.get_header('HTTP_CF_CONNECTING_IP')
56
+ ip
57
+ when forwarded_for = request.forwarded_for
58
+ forwarded_for.split(/\s*,\s*/).first&.sub(/:\d+$/, '')
59
+ else
60
+ request.ip
61
+ end
62
+ end
63
+
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'maxmind/db'
4
+
5
+ module Rack
6
+ module Dedos
7
+ module Filters
8
+ class Country < Base
9
+
10
+ # @option options [String] :maxmind_db_file MaxMind database file
11
+ # @option options [Symbol, Array<Symbol>] :allowed_countries ISO 3166-1 alpha 2
12
+ # @option options [Symbol, Array<Symbol>] :denied_countries ISO 3166-1 alpha 2
13
+ def initialize(*)
14
+ super
15
+ @maxmind_db_file = options[:maxmind_db_file] or fail "MaxMind database file not set"
16
+ @allowed = case
17
+ when @countries = options[:allowed_countries] then true
18
+ when @countries = options[:denied_countries] then false
19
+ else fail "neither allowed nor denied countries set"
20
+ end
21
+ maxmind_db # hit once to fail on errors at boot
22
+ end
23
+
24
+ def allowed?(request, ip)
25
+ if country = maxmind_db.get(ip)
26
+ country_code = country.dig('country', 'iso_code').to_sym
27
+ @countries.include?(country_code) ? @allowed : !@allowed
28
+ else # not found in database
29
+ true
30
+ end
31
+ rescue => error
32
+ warn("rack-dedos: request from #{ip} allowed due to error: #{error.message}")
33
+ true
34
+ end
35
+
36
+ private
37
+
38
+ def maxmind_db
39
+ config[:maxmind_db] ||= MaxMind::DB.new(
40
+ @maxmind_db_file,
41
+ mode: MaxMind::DB::MODE_FILE
42
+ )
43
+ end
44
+
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module Dedos
5
+ module Filters
6
+ class UserAgent < Base
7
+
8
+ # @option options [String] :cache_url URL of the cache backend
9
+ # @option options [Integer] :cache_period how long to retain cached IP
10
+ # addresses in seconds (default: 900)
11
+ def initialize(*)
12
+ super
13
+ @cache_url = options[:cache_url] or fail "cache URL not set"
14
+ @cache_period = options[:cache_period] || 900
15
+ @cache_key_prefix = options[:cache_key_prefix]
16
+ cache # hit once to fail on errors at boot
17
+ end
18
+
19
+ def allowed?(request, ip)
20
+ case cache.get(ip)
21
+ when nil # first contact
22
+ cache.set(ip, request.user_agent)
23
+ true
24
+ when 'BLOCKED' # already blocked
25
+ false
26
+ when request.user_agent # user agent hasn't changed
27
+ true
28
+ else # user agent has changed
29
+ cache.set(ip, 'BLOCKED')
30
+ false
31
+ end
32
+ rescue => error
33
+ warn("rack-dedos: request from #{ip} allowed due to error: #{error.message}")
34
+ true
35
+ end
36
+
37
+ private
38
+
39
+ def cache
40
+ config[:cache] ||= Cache.new(
41
+ url: @cache_url,
42
+ expires_in: @cache_period,
43
+ key_prefix: @cache_key_prefix
44
+ )
45
+ end
46
+
47
+ end
48
+ end
49
+ end
50
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rack
4
4
  module Dedos
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/rack/dedos.rb CHANGED
@@ -6,11 +6,9 @@ require_relative 'dedos/version'
6
6
 
7
7
  module Rack
8
8
  module Dedos
9
- lib_dir = ::File.expand_path(::File.dirname(__FILE__))
10
- autoload :Cache, lib_dir + '/dedos/cache'
11
- autoload :Base, lib_dir + '/dedos/base'
12
- autoload :UserAgent, lib_dir + '/dedos/user_agent'
13
- autoload :Country, lib_dir + '/dedos/country'
9
+
10
+ require_relative 'dedos/cache'
11
+ require_relative 'dedos/filters/base'
14
12
 
15
13
  class << self
16
14
  def config
@@ -21,8 +19,14 @@ module Rack
21
19
  except = Array options[:except]
22
20
 
23
21
  Rack::Builder.new do
24
- use(::Rack::Dedos::UserAgent, options) unless except.include? :user_agent
25
- use(::Rack::Dedos::Country, options) unless except.include? :country
22
+ unless except.include? :user_agent
23
+ require_relative 'dedos/filter/user_agent'
24
+ use(::Rack::Dedos::Filters::UserAgent, options)
25
+ end
26
+ unless except.include? :country
27
+ require_relative 'dedos/filter/country'
28
+ use(::Rack::Dedos::Filters::Country, options)
29
+ end
26
30
  run app
27
31
  end.to_app
28
32
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-dedos
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sven Schwyn
@@ -29,7 +29,7 @@ cert_chain:
29
29
  kAyiRqgxF4dJviwtqI7mZIomWL63+kXLgjOjMe1SHxfIPo/0ji6+r1p4KYa7o41v
30
30
  fwIwU1MKlFBdsjkd
31
31
  -----END CERTIFICATE-----
32
- date: 2023-02-03 00:00:00.000000000 Z
32
+ date: 2023-05-16 00:00:00.000000000 Z
33
33
  dependencies:
34
34
  - !ruby/object:Gem::Dependency
35
35
  name: rack
@@ -207,10 +207,10 @@ files:
207
207
  - LICENSE.txt
208
208
  - README.md
209
209
  - lib/rack/dedos.rb
210
- - lib/rack/dedos/base.rb
211
210
  - lib/rack/dedos/cache.rb
212
- - lib/rack/dedos/country.rb
213
- - lib/rack/dedos/user_agent.rb
211
+ - lib/rack/dedos/filters/base.rb
212
+ - lib/rack/dedos/filters/country.rb
213
+ - lib/rack/dedos/filters/user_agent.rb
214
214
  - lib/rack/dedos/version.rb
215
215
  homepage: https://github.com/svoop/rack-dedos
216
216
  licenses:
@@ -243,7 +243,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
243
243
  - !ruby/object:Gem::Version
244
244
  version: '0'
245
245
  requirements: []
246
- rubygems_version: 3.4.6
246
+ rubygems_version: 3.4.13
247
247
  signing_key:
248
248
  specification_version: 4
249
249
  summary: Radical filters to block denial-of-service (DoS) requests.
metadata.gz.sig CHANGED
Binary file
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Rack
4
- module Dedos
5
- class Base
6
-
7
- DEFAULT_OPTIONS = {
8
- status: 403,
9
- text: 'Forbidden (Temporarily Blocked by Rules)'
10
- }
11
-
12
- attr_reader :app
13
- attr_reader :options
14
-
15
- # @param app [#call]
16
- # @param options [Hash{Symbol => Object}]
17
- def initialize(app, options = {})
18
- @app = app
19
- @options = DEFAULT_OPTIONS.merge(options)
20
- end
21
-
22
- def call(env)
23
- request = Rack::Request.new(env)
24
- if allowed?(request)
25
- app.call(env)
26
- else
27
- warn("rack-dedos: request from #{request.ip} blocked by #{self.class}")
28
- [options[:status], { 'Content-Type' => 'text/plain' }, [options[:text]]]
29
- end
30
- end
31
-
32
- private
33
-
34
- def config
35
- Rack::Dedos.config
36
- end
37
-
38
- end
39
- end
40
- end
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'maxmind/db'
4
-
5
- module Rack
6
- module Dedos
7
- class Country < Base
8
-
9
- # @option options [String] :maxmind_db_file MaxMind database file
10
- # @option options [Symbol, Array<Symbol>] :allowed_countries ISO 3166-1 alpha 2
11
- # @option options [Symbol, Array<Symbol>] :denied_countries ISO 3166-1 alpha 2
12
- def initialize(*)
13
- super
14
- @maxmind_db_file = options[:maxmind_db_file] or fail "MaxMind database file not set"
15
- @allowed = case
16
- when @countries = options[:allowed_countries] then true
17
- when @countries = options[:denied_countries] then false
18
- else fail "neither allowed nor denied countries set"
19
- end
20
- maxmind_db # hit once to fail on errors at boot
21
- end
22
-
23
- def allowed?(request)
24
- if country = maxmind_db.get(request.ip)
25
- country_code = country.dig('country', 'iso_code').to_sym
26
- @countries.include?(country_code) ? @allowed : !@allowed
27
- else # not found in database
28
- true
29
- end
30
- rescue => error
31
- warn("rack-dedos: request from #{request.ip} allowed due to error: #{error.message}")
32
- true
33
- end
34
-
35
- private
36
-
37
- def maxmind_db
38
- config[:maxmind_db] ||= MaxMind::DB.new(
39
- @maxmind_db_file,
40
- mode: MaxMind::DB::MODE_FILE
41
- )
42
- end
43
-
44
- end
45
- end
46
- end
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Rack
4
- module Dedos
5
- class UserAgent < Base
6
-
7
- # @option options [String] :cache_url URL of the cache backend
8
- # @option options [Integer] :cache_period how long to retain cached IP
9
- # addresses in seconds (default: 900)
10
- def initialize(*)
11
- super
12
- @cache_url = options[:cache_url] or fail "cache URL not set"
13
- @cache_period = options[:cache_period] || 900
14
- @cache_key_prefix = options[:cache_key_prefix]
15
- cache # hit once to fail on errors at boot
16
- end
17
-
18
- def allowed?(request)
19
- case cache.get(request.ip)
20
- when nil # first contact
21
- cache.set(request.ip, request.user_agent)
22
- true
23
- when 'BLOCKED' # already blocked
24
- false
25
- when request.user_agent # user agent hasn't changed
26
- true
27
- else # user agent has changed
28
- cache.set(request.ip, 'BLOCKED')
29
- false
30
- end
31
- rescue => error
32
- warn("rack-dedos: request from #{request.ip} allowed due to error: #{error.message}")
33
- true
34
- end
35
-
36
- private
37
-
38
- def cache
39
- config[:cache] ||= Cache.new(
40
- url: @cache_url,
41
- expires_in: @cache_period,
42
- key_prefix: @cache_key_prefix
43
- )
44
- end
45
-
46
- end
47
- end
48
- end